Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 68 additions & 33 deletions components/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';

export type MenuItem = {
label: string;
action: () => void;
icon?: React.FC<{ className?: string }>;
disabled?: boolean;
shortcut?: string;
} | { type: 'separator' };
export type MenuItem =
| {
label: string;
action?: () => void;
icon?: React.FC<{ className?: string }>;
disabled?: boolean;
shortcut?: string;
children?: MenuItem[];
}
| { type: 'separator' };

interface ContextMenuProps {
isOpen: boolean;
Expand All @@ -20,6 +23,7 @@ const EDGE_MARGIN = 8;

const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onClose }) => {
const menuRef = useRef<HTMLDivElement>(null);
const [activeSubmenu, setActiveSubmenu] = useState<{ depth: number; index: number } | null>(null);
const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxHeight: number; overflowY: React.CSSProperties['overflowY'] }>({
top: position.y,
left: position.x,
Expand Down Expand Up @@ -95,6 +99,12 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onCl
};
}, [isOpen, onClose]);

useEffect(() => {
if (!isOpen) {
setActiveSubmenu(null);
}
}, [isOpen]);

useLayoutEffect(() => {
if (!isOpen) return;

Expand Down Expand Up @@ -146,6 +156,56 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onCl
const overlayRoot = document.getElementById('overlay-root');
if (!overlayRoot) return null;

const renderMenuItems = (menuItems: MenuItem[], depth = 0) => (
<ul className="space-y-1">
{menuItems.map((item, index) => {
if ('type' in item) {
return <li key={`separator-${depth}-${index}`} className="h-px bg-border-color my-1.5" />;
}

const { label, action, icon: Icon, disabled, shortcut, children } = item;
const hasChildren = Array.isArray(children) && children.length > 0;
const isSubmenuActive = activeSubmenu?.depth === depth && activeSubmenu.index === index;

return (
<li
key={`${label}-${depth}-${index}`}
className="relative"
onMouseEnter={() => setActiveSubmenu({ depth, index })}
onMouseLeave={() => setActiveSubmenu(prev => (prev?.depth === depth && prev.index === index ? null : prev))}
>
Comment on lines +172 to +176

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Submenus collapse when hovering child items

Hovering into a nested menu immediately removes the submenu, making its actions impossible to click. Each list item overwrites activeSubmenu with its own {depth, index} on hover (shown here) while submenu rendering later is gated by isSubmenuActive matching the parent’s depth; as soon as the pointer enters a child item the parent no longer matches and its submenu unmounts, so the new Table submenu in the rich text context menu disappears before any child action can be selected.

Useful? React with 👍 / 👎.

<button
onClick={() => {
if (!disabled && action && !hasChildren) {
action();
onClose();
}
}}
disabled={disabled}
className="w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-md transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-primary hover:text-primary-text focus:bg-primary focus:text-primary-text focus:outline-none"
>
<div className="flex items-center gap-3">
{Icon && <Icon className="w-4 h-4" />}
<span>{label}</span>
</div>
<div className="flex items-center gap-2 text-xs text-text-secondary">
{shortcut && <span>{shortcut}</span>}
{hasChildren && <span aria-hidden="true">›</span>}
</div>
</button>
{hasChildren && isSubmenuActive && (
<div className="absolute top-0 left-full ml-1">
<div className="w-[16rem] rounded-md bg-secondary p-1.5 shadow-2xl border border-border-color animate-fade-in-fast">
{renderMenuItems(children, depth + 1)}
</div>
</div>
)}
</li>
);
})}
</ul>
);

return ReactDOM.createPortal(
<div
ref={menuRef}
Expand All @@ -157,32 +217,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onCl
}}
className="fixed z-50 w-[16.8rem] rounded-md bg-secondary p-1.5 shadow-2xl border border-border-color animate-fade-in-fast"
>
<ul className="space-y-1">
{/* Fix: Restructured the type guard to check for a property on the desired object type directly, which ensures proper type narrowing for the MenuItem union. */}
{items.map((item, index) => {
if ('label' in item) {
const { label, action, icon: Icon, disabled, shortcut } = item;

return (
<li key={label}>
<button
onClick={() => { if(!disabled) { action(); onClose(); } }}
disabled={disabled}
className="w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-md transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-primary hover:text-primary-text focus:bg-primary focus:text-primary-text focus:outline-none"
>
<div className="flex items-center gap-3">
{Icon && <Icon className="w-4 h-4" />}
<span>{label}</span>
</div>
{shortcut && <span className="text-xs text-text-secondary">{shortcut}</span>}
</button>
</li>
);
} else {
return <li key={`separator-${index}`} className="h-px bg-border-color my-1.5" />;
}
})}
</ul>
{renderMenuItems(items)}
<style>{`
@keyframes fade-in-fast {
from { opacity: 0; transform: scale(0.95); }
Expand Down
93 changes: 86 additions & 7 deletions components/RichTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,31 @@ const ToolbarPlugin: React.FC<{
[runWithActiveTable],
);

const selectTable = useCallback(
() =>
runWithActiveTable(selection => {
const anchorCell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode());
if (!anchorCell) {
return;
}
const tableNode = $getTableNodeFromLexicalNodeOrThrow(anchorCell);
const firstRow = tableNode.getFirstChild();
const lastRow = tableNode.getLastChild();
if (!$isTableRowNode(firstRow) || !$isTableRowNode(lastRow)) {
return;
}
const firstCell = firstRow.getFirstChild();
const lastCell = lastRow.getLastChild();
if (!$isTableCellNode(firstCell) || !$isTableCellNode(lastCell)) {
return;
}
const tableSelection = $createTableSelection();
tableSelection.set(tableNode.getKey(), firstCell.getKey(), lastCell.getKey());
$setSelection(tableSelection);
}),
[runWithActiveTable],
);

const openImagePicker = useCallback(() => {
if (readOnly) {
return;
Expand Down Expand Up @@ -1586,20 +1611,74 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
}

const items: ContextMenuItem[] = [];
const tableSubmenuItems: ContextMenuItem[] = [
{
label: 'Insert column before',
action: () => insertTableColumn(false),
disabled: !isInTable,
},
{
label: 'Insert column after',
action: () => insertTableColumn(true),
disabled: !isInTable,
},
{
label: 'Insert row before',
action: () => insertTableRow(false),
disabled: !isInTable,
},
{
label: 'Insert row after',
action: () => insertTableRow(true),
disabled: !isInTable,
},
{ type: 'separator' },
{
label: 'Delete row',
action: deleteTableRow,
disabled: !isInTable,
},
{
label: 'Delete column',
action: deleteTableColumn,
disabled: !isInTable,
},
{
label: 'Delete table',
action: deleteTable,
disabled: !isInTable,
},
{ type: 'separator' },
{
label: 'Select table',
action: selectTable,
disabled: !isInTable,
},
];

contextActions.forEach((action, index) => {
const previous = contextActions[index - 1];
if (previous && previous.group !== action.group) {
items.push({ type: 'separator' });
}
items.push({
label: action.label,
action: action.onClick,
icon: action.icon,
disabled: action.disabled,
});
if (action.id === 'table') {
items.push({
label: 'Table',
icon: action.icon,
disabled: action.disabled,
children: tableSubmenuItems,
});
} else {
items.push({
label: action.label,
action: action.onClick,
icon: action.icon,
disabled: action.disabled,
});
}
});
return items;
}, [contextActions, readOnly]);
}, [contextActions, deleteTable, deleteTableColumn, deleteTableRow, insertTableColumn, insertTableRow, isInTable, readOnly, selectTable]);

const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
Expand Down