/** * Utility for positioning floating menus relative to editor selections or cursor positions */ export class MenuPositioner { /** * Position a menu element relative to a text selection or cursor position * @param {HTMLElement} menuElement - The menu element to position * @param {Editor} editorInstance - The TipTap editor instance * @param {Object|null} clientRect - Optional custom client rect for positioning */ static positionMenu(menuElement, editorInstance, clientRect = null) { const { view } = editorInstance; const { selection } = editorInstance.state; let start, end; if (clientRect) { // Ensure clientRect has all required properties start = { left: clientRect.left || 0, right: clientRect.right || clientRect.left || 0, top: clientRect.top || 0, bottom: clientRect.bottom || clientRect.top || 0, }; end = start; } else { try { start = view.coordsAtPos(selection.from); end = view.coordsAtPos(selection.to); } catch (error) { console.warn('Failed to get coordinates from editor selection:', error); // Fallback to a default position start = { left: 10, top: 10, right: 20, bottom: 20 }; end = start; } } // Validate coordinates if (!start || typeof start.left !== 'number' || typeof start.top !== 'number') { console.warn('Invalid start coordinates, using fallback:', start); start = { left: 10, top: 10, right: 20, bottom: 20 }; } if (!end || typeof end.left !== 'number' || typeof end.top !== 'number') { console.warn('Invalid end coordinates, using start coordinates:', end); end = start; } // Calculate the center of the selection let left = (start.left + end.left) / 2; // Get menu dimensions - ensure menu is visible to get accurate measurements const wasHidden = menuElement.style.display === 'none'; if (wasHidden) { menuElement.style.visibility = 'hidden'; menuElement.style.display = 'block'; } const menuWidth = menuElement.offsetWidth || 200; // fallback width const menuHeight = menuElement.offsetHeight || 100; // fallback height if (wasHidden) { menuElement.style.display = 'none'; menuElement.style.visibility = ''; } // Start by trying to position above the selection let top = start.top - menuHeight - 10; // 10px above selection let placement = 'above'; // If the calculated top is negative or too close to viewport top, position below if (top < 10) { top = (end.bottom || end.top || start.top) + 10; // 10px below selection placement = 'below'; } // Adjust horizontal position if it goes out of viewport const viewportWidth = window.innerWidth; // Check if menu would overflow on the right if (left + menuWidth / 2 > viewportWidth - 10) { left = viewportWidth - menuWidth - 10; // Align to right edge with padding } // Check if menu would overflow on the left else if (left - menuWidth / 2 < 10) { left = 10; // Align to left edge with padding } // Center the menu horizontally on the selection else { left = left - menuWidth / 2; } // Final validation if (isNaN(left) || isNaN(top)) { console.warn('NaN detected in basic positioning, using fallback values:', { left, top }); left = isNaN(left) ? 10 : left; top = isNaN(top) ? 10 : top; } // Apply positioning menuElement.style.left = `${left}px`; menuElement.style.top = `${top}px`; // Add a data attribute to indicate placement for potential styling menuElement.setAttribute('data-placement', placement); console.log(`Menu positioned ${placement} selection at: left=${left}px, top=${top}px`); return { left, top, placement }; } /** * Position a menu element with enhanced viewport awareness * Includes additional checks for viewport boundaries and scroll position * @param {HTMLElement} menuElement - The menu element to position * @param {Editor} editorInstance - The TipTap editor instance * @param {Object|null} clientRect - Optional custom client rect for positioning */ static positionMenuAdvanced(menuElement, editorInstance, clientRect = null) { const { view } = editorInstance; const { selection } = editorInstance.state; console.log('positionMenuAdvanced called with:', { clientRect, selection: selection.from + '-' + selection.to }); let start, end; if (clientRect) { console.log('Using clientRect:', clientRect); // Ensure clientRect has all required properties start = { left: clientRect.left || 0, right: clientRect.right || clientRect.left || 0, top: clientRect.top || 0, bottom: clientRect.bottom || clientRect.top || 0, }; end = start; } else { try { start = view.coordsAtPos(selection.from); end = view.coordsAtPos(selection.to); console.log('Using editor coordinates:', { start, end }); } catch (error) { console.warn('Failed to get coordinates from editor selection, falling back to basic positioning:', error); return MenuPositioner.positionMenu(menuElement, editorInstance, clientRect); } } // Validate coordinates - be more strict about what we consider valid if (!start || typeof start.left !== 'number' || isNaN(start.left) || typeof start.top !== 'number' || isNaN(start.top) || start.left === 0 && start.top === 0) { console.warn('Invalid or zero coordinates detected, using editor selection instead:', { start, end }); // Try to get coordinates from current editor selection try { start = view.coordsAtPos(selection.from); end = view.coordsAtPos(selection.to); console.log('Fallback to editor coordinates worked:', { start, end }); } catch (error) { console.warn('Editor coordinates also failed, using basic positioning'); return MenuPositioner.positionMenu(menuElement, editorInstance, null); } } // Get viewport dimensions and scroll position const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const scrollY = window.scrollY; // Get menu dimensions - ensure menu is visible to get accurate measurements const wasHidden = menuElement.style.display === 'none'; if (wasHidden) { menuElement.style.visibility = 'hidden'; menuElement.style.display = 'block'; } const menuWidth = menuElement.offsetWidth || 200; // fallback width const menuHeight = menuElement.offsetHeight || 100; // fallback height if (wasHidden) { menuElement.style.display = 'none'; menuElement.style.visibility = ''; } // Calculate initial center position let left = (start.left + end.left) / 2; let top, placement; // Determine vertical placement based on available space const spaceAbove = start.top - scrollY; const spaceBelow = viewportHeight - (end.bottom - scrollY); if (spaceAbove >= menuHeight + 20 && spaceAbove > spaceBelow) { // Position above if there's enough space and it's the better option top = start.top - menuHeight - 10; placement = 'above'; } else if (spaceBelow >= menuHeight + 20) { // Position below if there's enough space top = end.bottom + 10; placement = 'below'; } else { // Choose the side with more space if (spaceAbove > spaceBelow) { top = Math.max(scrollY + 10, start.top - menuHeight - 10); placement = 'above-constrained'; } else { top = Math.min(scrollY + viewportHeight - menuHeight - 10, end.bottom + 10); placement = 'below-constrained'; } } // Adjust horizontal position const idealLeft = left - menuWidth / 2; if (idealLeft < 10) { left = 10; } else if (idealLeft + menuWidth > viewportWidth - 10) { left = viewportWidth - menuWidth - 10; } else { left = idealLeft; } // Final validation if (isNaN(left) || isNaN(top)) { console.warn('NaN detected in positioning calculation, using fallback values:', { left, top, start, end }); left = isNaN(left) ? 10 : left; top = isNaN(top) ? 10 : top; } // Apply positioning menuElement.style.left = `${left}px`; menuElement.style.top = `${top}px`; menuElement.setAttribute('data-placement', placement); console.log(`Menu positioned ${placement} at: left=${left}px, top=${top}px (viewport: ${viewportWidth}x${viewportHeight}, scroll: ${scrollY})`); return { left, top, placement }; } }