Skip to content

Commit 99e3318

Browse files
authored
Merge pull request #271 from beNative/tisi/reorganize-rich-text-editor-context-menu
Add selection submenu to rich text context menu
2 parents b211c08 + 3d0dd3f commit 99e3318

2 files changed

Lines changed: 157 additions & 41 deletions

File tree

components/ContextMenu.tsx

Lines changed: 104 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
22
import ReactDOM from 'react-dom';
33

4-
export type MenuItem = {
5-
label: string;
6-
action: () => void;
7-
icon?: React.FC<{ className?: string }>;
8-
disabled?: boolean;
9-
shortcut?: string;
10-
} | { type: 'separator' };
4+
export type MenuItem =
5+
| {
6+
label: string;
7+
action: () => void;
8+
icon?: React.FC<{ className?: string }>;
9+
disabled?: boolean;
10+
shortcut?: string;
11+
submenu?: never;
12+
}
13+
| {
14+
label: string;
15+
submenu: MenuItem[];
16+
icon?: React.FC<{ className?: string }>;
17+
disabled?: boolean;
18+
shortcut?: string;
19+
action?: never;
20+
}
21+
| { type: 'separator' };
1122

1223
interface ContextMenuProps {
1324
isOpen: boolean;
@@ -20,6 +31,7 @@ const EDGE_MARGIN = 8;
2031

2132
const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onClose }) => {
2233
const menuRef = useRef<HTMLDivElement>(null);
34+
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
2335
const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxHeight: number; overflowY: React.CSSProperties['overflowY'] }>({
2436
top: position.y,
2537
left: position.x,
@@ -76,6 +88,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onCl
7688
useEffect(() => {
7789
if (!isOpen) return;
7890

91+
setOpenSubmenu(null);
92+
7993
const handleClickOutside = (event: MouseEvent) => {
8094
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
8195
onClose();
@@ -146,6 +160,88 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onCl
146160
const overlayRoot = document.getElementById('overlay-root');
147161
if (!overlayRoot) return null;
148162

163+
const hasEnabledItem = (menuItems: MenuItem[]): boolean => {
164+
return menuItems.some(item => {
165+
if ('type' in item) {
166+
return false;
167+
}
168+
169+
if ('submenu' in item) {
170+
return !item.disabled && hasEnabledItem(item.submenu);
171+
}
172+
173+
return !item.disabled;
174+
});
175+
};
176+
177+
const renderItems = (menuItems: MenuItem[], depth = 0) => {
178+
return menuItems.map((item, index) => {
179+
if ('type' in item) {
180+
return <li key={`separator-${depth}-${index}`} className="h-px bg-border-color my-1.5" />;
181+
}
182+
183+
if ('submenu' in item) {
184+
const menuKey = `${depth}-${index}`;
185+
const isOpen = openSubmenu === menuKey;
186+
const hasEnabledSubitem = hasEnabledItem(item.submenu);
187+
const Icon = item.icon;
188+
189+
return (
190+
<li
191+
key={`${item.label}-${depth}-${index}`}
192+
className="relative"
193+
onMouseEnter={() => setOpenSubmenu(menuKey)}
194+
onMouseLeave={() => setOpenSubmenu(null)}
195+
>
196+
<button
197+
onClick={() => {
198+
if (!item.disabled && hasEnabledSubitem) {
199+
setOpenSubmenu(menuKey);
200+
}
201+
}}
202+
disabled={item.disabled || !hasEnabledSubitem}
203+
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"
204+
>
205+
<div className="flex items-center gap-3">
206+
{Icon && <Icon className="w-4 h-4" />}
207+
<span>{item.label}</span>
208+
</div>
209+
<span className="text-text-secondary"></span>
210+
</button>
211+
{isOpen && item.submenu.length > 0 && (
212+
<div className="absolute top-0 left-full ml-1 z-10 w-[16.8rem] rounded-md bg-secondary p-1.5 shadow-2xl border border-border-color animate-fade-in-fast">
213+
<ul className="space-y-1">{renderItems(item.submenu, depth + 1)}</ul>
214+
</div>
215+
)}
216+
</li>
217+
);
218+
}
219+
220+
const { label, action, icon: Icon, disabled, shortcut } = item;
221+
222+
return (
223+
<li key={`${label}-${depth}-${index}`}>
224+
<button
225+
onClick={() => {
226+
if (!disabled) {
227+
action();
228+
onClose();
229+
}
230+
}}
231+
disabled={disabled}
232+
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"
233+
>
234+
<div className="flex items-center gap-3">
235+
{Icon && <Icon className="w-4 h-4" />}
236+
<span>{label}</span>
237+
</div>
238+
{shortcut && <span className="text-xs text-text-secondary">{shortcut}</span>}
239+
</button>
240+
</li>
241+
);
242+
});
243+
};
244+
149245
return ReactDOM.createPortal(
150246
<div
151247
ref={menuRef}
@@ -158,30 +254,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onCl
158254
className="fixed z-50 w-[16.8rem] rounded-md bg-secondary p-1.5 shadow-2xl border border-border-color animate-fade-in-fast"
159255
>
160256
<ul className="space-y-1">
161-
{/* 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. */}
162-
{items.map((item, index) => {
163-
if ('label' in item) {
164-
const { label, action, icon: Icon, disabled, shortcut } = item;
165-
166-
return (
167-
<li key={label}>
168-
<button
169-
onClick={() => { if(!disabled) { action(); onClose(); } }}
170-
disabled={disabled}
171-
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"
172-
>
173-
<div className="flex items-center gap-3">
174-
{Icon && <Icon className="w-4 h-4" />}
175-
<span>{label}</span>
176-
</div>
177-
{shortcut && <span className="text-xs text-text-secondary">{shortcut}</span>}
178-
</button>
179-
</li>
180-
);
181-
} else {
182-
return <li key={`separator-${index}`} className="h-px bg-border-color my-1.5" />;
183-
}
184-
})}
257+
{renderItems(items)}
185258
</ul>
186259
<style>{`
187260
@keyframes fade-in-fast {

components/RichTextEditor.tsx

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1585,20 +1585,63 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
15851585
return [];
15861586
}
15871587

1588+
const mapActionsToMenuItems = (actions: ToolbarButtonConfig[]) => {
1589+
const items: ContextMenuItem[] = [];
1590+
actions.forEach((action, index) => {
1591+
const previous = actions[index - 1];
1592+
if (previous && previous.group !== action.group) {
1593+
items.push({ type: 'separator' });
1594+
}
1595+
items.push({
1596+
label: action.label,
1597+
action: action.onClick,
1598+
icon: action.icon,
1599+
disabled: action.disabled,
1600+
});
1601+
});
1602+
return items;
1603+
};
1604+
1605+
const historyActions = contextActions.filter(action => action.group === 'history');
1606+
const selectionActions = contextActions.filter(action =>
1607+
['inline-format', 'alignment', 'structure', 'utility'].includes(action.group),
1608+
);
1609+
const insertActions = contextActions.filter(action => action.group === 'insert');
1610+
15881611
const items: ContextMenuItem[] = [];
1589-
contextActions.forEach((action, index) => {
1590-
const previous = contextActions[index - 1];
1591-
if (previous && previous.group !== action.group) {
1592-
items.push({ type: 'separator' });
1593-
}
1612+
items.push(...mapActionsToMenuItems(historyActions));
1613+
1614+
if (items.length > 0 && selectionActions.length > 0 && items[items.length - 1]?.type !== 'separator') {
1615+
items.push({ type: 'separator' });
1616+
}
1617+
1618+
if (selectionActions.length > 0) {
15941619
items.push({
1595-
label: action.label,
1596-
action: action.onClick,
1597-
icon: action.icon,
1598-
disabled: action.disabled,
1620+
label: 'Selection',
1621+
submenu: mapActionsToMenuItems(selectionActions),
1622+
disabled: selectionActions.every(action => action.disabled),
15991623
});
1624+
}
1625+
1626+
if (items.length > 0 && insertActions.length > 0) {
1627+
items.push({ type: 'separator' });
1628+
}
1629+
1630+
items.push(...mapActionsToMenuItems(insertActions));
1631+
1632+
const cleanedItems = items.filter((item, index, array) => {
1633+
if (item.type !== 'separator') {
1634+
return true;
1635+
}
1636+
1637+
const isLeadingSeparator = index === 0;
1638+
const isTrailingSeparator = index === array.length - 1;
1639+
const isDuplicateSeparator = array[index - 1]?.type === 'separator' || array[index + 1]?.type === 'separator';
1640+
1641+
return !isLeadingSeparator && !isTrailingSeparator && !isDuplicateSeparator;
16001642
});
1601-
return items;
1643+
1644+
return cleanedItems;
16021645
}, [contextActions, readOnly]);
16031646

16041647
const handleScroll = useCallback(

0 commit comments

Comments
 (0)