import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Tree } from 'react-arborist'; import { useI18n } from '../../i18n'; import { showToast } from '../Toast'; import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi'; import { PageInput } from '../PageInput'; import { createAutoExpandController } from './menuAutoExpand'; import { resolveInsertTarget } from './menuInsertTarget'; import { isPickerCloseKey } from './menuPagePicker'; import { applyTreeMove } from './menuTreeMove'; import './MenuEditorView.css'; interface ToolButtonProps { label: string; disabled?: boolean; onClick: () => void; onShowTooltip: (label: string) => void; onHideTooltip: () => void; children: React.ReactNode; } const ToolButton: React.FC = ({ label, disabled, onClick, onShowTooltip, onHideTooltip, children, }) => (
); function createMenuItemId(): string { return `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } function findPathById(items: MenuItemData[], id: string, path: number[] = []): number[] | null { for (let index = 0; index < items.length; index += 1) { const item = items[index]; const nextPath = [...path, index]; if (item.id === id) { return nextPath; } const nested = findPathById(item.children, id, nextPath); if (nested) { return nested; } } return null; } function removeItemByPath(items: MenuItemData[], path: number[]): { next: MenuItemData[]; removed: MenuItemData | null } { if (path.length === 0) { return { next: items, removed: null }; } if (path.length === 1) { const [index] = path; if (index < 0 || index >= items.length) { return { next: items, removed: null }; } const removed = items[index]; return { next: items.filter((_, currentIndex) => currentIndex !== index), removed, }; } const [head, ...tail] = path; const current = items[head]; if (!current) { return { next: items, removed: null }; } const nested = removeItemByPath(current.children, tail); if (!nested.removed) { return { next: items, removed: null }; } const next = items.map((item, index) => (index === head ? { ...item, children: nested.next } : item)); return { next, removed: nested.removed }; } function updateItemsAtLevel( items: MenuItemData[], path: number[], updater: (level: MenuItemData[]) => MenuItemData[], ): MenuItemData[] { if (path.length === 0) { return updater(items); } const [head, ...tail] = path; return items.map((item, index) => { if (index !== head) { return item; } return { ...item, children: updateItemsAtLevel(item.children, tail, updater), }; }); } function insertItemAtPath(items: MenuItemData[], parentPath: number[], index: number, node: MenuItemData): MenuItemData[] { if (parentPath.length === 0) { const boundedIndex = Math.max(0, Math.min(index, items.length)); return [...items.slice(0, boundedIndex), node, ...items.slice(boundedIndex)]; } const [head, ...tail] = parentPath; return items.map((item, currentIndex) => { if (currentIndex !== head) { return item; } return { ...item, children: insertItemAtPath(item.children, tail, index, node), }; }); } function mapItems(items: MenuItemData[], mapper: (item: MenuItemData) => MenuItemData): MenuItemData[] { return items.map((item) => { const mapped = mapper(item); if (mapped.children.length === 0) { return mapped; } return { ...mapped, children: mapItems(mapped.children, mapper), }; }); } function createDraftEntry(): MenuItemData { return { id: createMenuItemId(), title: '', kind: 'submenu', pageId: undefined, pageSlug: undefined, children: [], }; } export const MenuEditorView: React.FC = () => { const { t: tr } = useI18n(); const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [isLoadingPages, setIsLoadingPages] = useState(false); const [pagePosts, setPagePosts] = useState([]); const [editingEntryId, setEditingEntryId] = useState(null); const [toolbarTooltip, setToolbarTooltip] = useState(''); const [recentParentInsertId, setRecentParentInsertId] = useState(null); const recentInsertTimerRef = useRef | null>(null); const autoExpandController = useMemo(() => createAutoExpandController(450), []); useEffect(() => { const load = async () => { setIsLoading(true); try { const menu = await window.electronAPI.menu.get(); setItems(menu.items); setSelectedId(menu.items[0]?.id ?? null); } catch (error) { console.error('Failed to load menu:', error); showToast.error(tr('menuEditor.loadError')); } finally { setIsLoading(false); } }; void load(); }, [tr]); useEffect(() => { return () => { autoExpandController.cancelAll(); if (recentInsertTimerRef.current) { clearTimeout(recentInsertTimerRef.current); } }; }, [autoExpandController]); useEffect(() => { if (!editingEntryId) { return; } const onWindowKeyDown = (event: KeyboardEvent): void => { if (isPickerCloseKey(event.key)) { event.preventDefault(); setItems((previous) => { const path = findPathById(previous, editingEntryId); if (!path) { return previous; } return removeItemByPath(previous, path).next; }); setEditingEntryId(null); } }; document.addEventListener('keydown', onWindowKeyDown); return () => { document.removeEventListener('keydown', onWindowKeyDown); }; }, [editingEntryId]); useEffect(() => { if (!editingEntryId || isLoadingPages) { return; } const focusInput = (): void => { const input = document.querySelector('.menu-editor-row-title.is-editing .tag-input-field') as HTMLInputElement | null; if (!input) { return; } input.focus(); input.select(); }; const immediate = setTimeout(focusInput, 0); const delayed = setTimeout(focusInput, 32); return () => { clearTimeout(immediate); clearTimeout(delayed); }; }, [editingEntryId, isLoadingPages]); const selectedPath = useMemo(() => { if (!selectedId) { return null; } return findPathById(items, selectedId); }, [items, selectedId]); const ensurePagePostsLoaded = async (): Promise => { if (pagePosts.length > 0) { return; } setIsLoadingPages(true); try { const posts = await window.electronAPI.posts.filter({ categories: ['page'] }); setPagePosts(posts); } catch (error) { console.error('Failed to load page posts:', error); showToast.error(tr('menuEditor.pagePicker.loadError')); setPagePosts([]); } finally { setIsLoadingPages(false); } }; const setDraftAsSubmenu = (label: string): void => { if (!editingEntryId) { return; } const trimmed = label.trim(); const nextTitle = trimmed || tr('menuEditor.newSubmenu'); setItems((previous) => mapItems(previous, (item) => { if (item.id !== editingEntryId) { return item; } return { ...item, title: nextTitle, kind: 'submenu', pageId: undefined, pageSlug: undefined, }; })); setEditingEntryId(null); }; const setDraftAsPage = (post: PostData): void => { if (!editingEntryId) { return; } setItems((previous) => mapItems(previous, (item) => { if (item.id !== editingEntryId) { return item; } return { ...item, title: post.title, kind: 'page', pageId: post.id, pageSlug: post.slug, }; })); setEditingEntryId(null); }; const startCreateEntry = async (): Promise => { await ensurePagePostsLoaded(); const newEntry = createDraftEntry(); const target = resolveInsertTarget(items, selectedId); if (target.parentPath.length > 0) { const parentPath = target.parentPath; let parentNode: MenuItemData | null = null; let currentLevel = items; for (const segment of parentPath) { parentNode = currentLevel[segment] || null; if (!parentNode) { break; } currentLevel = parentNode.children; } if (parentNode) { setRecentParentInsertId(parentNode.id); if (recentInsertTimerRef.current) { clearTimeout(recentInsertTimerRef.current); } recentInsertTimerRef.current = setTimeout(() => { setRecentParentInsertId(null); recentInsertTimerRef.current = null; }, 900); } } setItems((previous) => { return insertItemAtPath(previous, target.parentPath, target.index, newEntry); }); setSelectedId(newEntry.id); setEditingEntryId(newEntry.id); }; const save = async (): Promise => { setIsSaving(true); try { const payload: MenuDocument = { items }; const saved = await window.electronAPI.menu.save(payload); setItems(saved.items); showToast.success(tr('menuEditor.saved')); } catch (error) { console.error('Failed to save menu:', error); showToast.error(tr('menuEditor.saveFailed')); } finally { setIsSaving(false); } }; const moveSelected = (direction: 'up' | 'down'): void => { if (!selectedPath || selectedPath.length === 0) { return; } const parentPath = selectedPath.slice(0, -1); const index = selectedPath[selectedPath.length - 1]; const delta = direction === 'up' ? -1 : 1; setItems((previous) => updateItemsAtLevel(previous, parentPath, (level) => { const targetIndex = index + delta; if (targetIndex < 0 || targetIndex >= level.length) { return level; } const next = [...level]; const [moved] = next.splice(index, 1); next.splice(targetIndex, 0, moved); return next; })); }; const indentSelected = (): void => { if (!selectedPath || selectedPath.length === 0) { return; } const index = selectedPath[selectedPath.length - 1]; if (index <= 0) { return; } const parentPath = selectedPath.slice(0, -1); setItems((previous) => { const removed = removeItemByPath(previous, selectedPath); if (!removed.removed) { return previous; } const previousSiblingPath = [...parentPath, index - 1]; return updateItemsAtLevel(removed.next, previousSiblingPath, (level) => [...level, removed.removed as MenuItemData]); }); }; const unindentSelected = (): void => { if (!selectedPath || selectedPath.length < 2) { return; } const parentPath = selectedPath.slice(0, -1); const parentIndex = parentPath[parentPath.length - 1]; const grandParentPath = parentPath.slice(0, -1); setItems((previous) => { const removed = removeItemByPath(previous, selectedPath); if (!removed.removed) { return previous; } return insertItemAtPath(removed.next, grandParentPath, parentIndex + 1, removed.removed); }); }; const deleteSelected = (): void => { if (!selectedPath || selectedPath.length === 0 || !selectedId) { return; } setItems((previous) => { const removed = removeItemByPath(previous, selectedPath); return removed.next; }); if (editingEntryId === selectedId) { setEditingEntryId(null); } setSelectedId(null); }; return (

{tr('menuEditor.title')}

{tr('menuEditor.description')}

{isLoading ? (
{tr('menuEditor.loading')}
) : (
void startCreateEntry()} onShowTooltip={setToolbarTooltip} onHideTooltip={() => setToolbarTooltip('')} > void save()} disabled={isSaving} onShowTooltip={setToolbarTooltip} onHideTooltip={() => setToolbarTooltip('')} > moveSelected('up')} disabled={!selectedPath || selectedPath[selectedPath.length - 1] === 0} onShowTooltip={setToolbarTooltip} onHideTooltip={() => setToolbarTooltip('')} > moveSelected('down')} disabled={!selectedPath} onShowTooltip={setToolbarTooltip} onHideTooltip={() => setToolbarTooltip('')} > setToolbarTooltip('')} > setToolbarTooltip('')} > setToolbarTooltip('')} >
{toolbarTooltip || '\u00A0'}
{items.length === 0 ? (
{tr('menuEditor.empty')}
) : ( data={items} width="100%" height={editingEntryId ? 320 : 460} rowHeight={32} indent={20} openByDefault disableEdit disableMultiSelection onMove={({ dragIds, parentId, index }) => { setItems((previous) => applyTreeMove(previous, { dragIds, parentId, index, })); }} onSelect={(nodes) => { setSelectedId(nodes[0]?.data.id || null); }} > {({ node, style, tree, dragHandle }) => (
setSelectedId(node.data.id)} onMouseEnter={() => { if (!tree.dragNode || !node.isInternal || node.isOpen) { autoExpandController.cancel(node.id); return; } autoExpandController.schedule(node.id, () => { node.open(); }); }} onMouseLeave={() => { autoExpandController.cancel(node.id); }} > <> {node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')} {editingEntryId === node.data.id ? ( ) : node.data.title}
)} )}
)}
); };