614 lines
19 KiB
TypeScript
614 lines
19 KiB
TypeScript
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<ToolButtonProps> = ({
|
|
label,
|
|
disabled,
|
|
onClick,
|
|
onShowTooltip,
|
|
onHideTooltip,
|
|
children,
|
|
}) => (
|
|
<div className="menu-editor-tool-wrap">
|
|
<button
|
|
type="button"
|
|
className="menu-editor-tool"
|
|
aria-label={label}
|
|
onClick={onClick}
|
|
onMouseEnter={() => onShowTooltip(label)}
|
|
onMouseLeave={onHideTooltip}
|
|
onFocus={() => onShowTooltip(label)}
|
|
onBlur={onHideTooltip}
|
|
disabled={disabled}
|
|
>
|
|
{children}
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
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<MenuItemData[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isLoadingPages, setIsLoadingPages] = useState(false);
|
|
const [pagePosts, setPagePosts] = useState<PostData[]>([]);
|
|
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
|
const [toolbarTooltip, setToolbarTooltip] = useState<string>('');
|
|
const [recentParentInsertId, setRecentParentInsertId] = useState<string | null>(null);
|
|
const recentInsertTimerRef = useRef<ReturnType<typeof setTimeout> | 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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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 (
|
|
<div className="menu-editor-view">
|
|
<div className="menu-editor-header">
|
|
<div>
|
|
<h2>{tr('menuEditor.title')}</h2>
|
|
<p>{tr('menuEditor.description')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="menu-editor-loading">{tr('menuEditor.loading')}</div>
|
|
) : (
|
|
<div className="menu-editor-main">
|
|
<div className="menu-editor-tree-wrap">
|
|
<div className="menu-editor-toolbar" role="toolbar" aria-label={tr('menuEditor.title')}>
|
|
<ToolButton
|
|
label={tr('menuEditor.addEntry')}
|
|
onClick={() => void startCreateEntry()}
|
|
onShowTooltip={setToolbarTooltip}
|
|
onHideTooltip={() => setToolbarTooltip('')}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5V2z" /></svg>
|
|
</ToolButton>
|
|
<ToolButton
|
|
label={tr('menuEditor.save')}
|
|
onClick={() => void save()}
|
|
disabled={isSaving}
|
|
onShowTooltip={setToolbarTooltip}
|
|
onHideTooltip={() => setToolbarTooltip('')}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
|
|
</ToolButton>
|
|
<ToolButton
|
|
label={tr('menuEditor.moveUp')}
|
|
onClick={() => moveSelected('up')}
|
|
disabled={!selectedPath || selectedPath[selectedPath.length - 1] === 0}
|
|
onShowTooltip={setToolbarTooltip}
|
|
onHideTooltip={() => setToolbarTooltip('')}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
|
|
</ToolButton>
|
|
<ToolButton
|
|
label={tr('menuEditor.moveDown')}
|
|
onClick={() => moveSelected('down')}
|
|
disabled={!selectedPath}
|
|
onShowTooltip={setToolbarTooltip}
|
|
onHideTooltip={() => setToolbarTooltip('')}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
|
|
</ToolButton>
|
|
<ToolButton
|
|
label={tr('menuEditor.indent')}
|
|
onClick={indentSelected}
|
|
disabled={!selectedPath || selectedPath[selectedPath.length - 1] === 0}
|
|
onShowTooltip={setToolbarTooltip}
|
|
onHideTooltip={() => setToolbarTooltip('')}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm6-1 3 2-3 2V9z" /></svg>
|
|
</ToolButton>
|
|
<ToolButton
|
|
label={tr('menuEditor.unindent')}
|
|
onClick={unindentSelected}
|
|
disabled={!selectedPath || selectedPath.length < 2}
|
|
onShowTooltip={setToolbarTooltip}
|
|
onHideTooltip={() => setToolbarTooltip('')}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm3-1-3 2 3 2V9z" /></svg>
|
|
</ToolButton>
|
|
<ToolButton
|
|
label={tr('menuEditor.delete')}
|
|
onClick={deleteSelected}
|
|
disabled={!selectedPath}
|
|
onShowTooltip={setToolbarTooltip}
|
|
onHideTooltip={() => setToolbarTooltip('')}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
|
|
</ToolButton>
|
|
<div className="menu-editor-toolbar-tooltip" role="tooltip" aria-live="polite">
|
|
{toolbarTooltip || '\u00A0'}
|
|
</div>
|
|
</div>
|
|
|
|
{items.length === 0 ? (
|
|
<div className="menu-editor-empty">{tr('menuEditor.empty')}</div>
|
|
) : (
|
|
<Tree<MenuItemData>
|
|
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 }) => (
|
|
<div
|
|
ref={dragHandle}
|
|
data-drag-handle="true"
|
|
style={style}
|
|
className={`menu-editor-row ${selectedId === node.data.id ? 'is-selected' : ''} ${recentParentInsertId === node.data.id ? 'is-parent-target' : ''}`}
|
|
onClick={() => 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);
|
|
}}
|
|
>
|
|
<>
|
|
<span className="menu-editor-row-kind">
|
|
{node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')}
|
|
</span>
|
|
<span className={`menu-editor-row-title ${editingEntryId === node.data.id ? 'is-editing' : ''}`}>
|
|
{editingEntryId === node.data.id ? (
|
|
<PageInput
|
|
pages={pagePosts}
|
|
onSelectPage={setDraftAsPage}
|
|
onCreateSubmenu={setDraftAsSubmenu}
|
|
createSubmenuLabel={tr('menuEditor.addSubmenu')}
|
|
placeholder={tr('menuEditor.newEntryPlaceholder')}
|
|
disabled={isLoadingPages}
|
|
autoFocus
|
|
inlinePlain
|
|
/>
|
|
) : node.data.title}
|
|
</span>
|
|
</>
|
|
</div>
|
|
)}
|
|
</Tree>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|