Files
tp-rich-text-box/menu-positioner.js

236 lines
8.6 KiB
JavaScript

/**
* 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 };
}
}