236 lines
8.6 KiB
JavaScript
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 };
|
|
}
|
|
} |