feat: rudimentary menu working now
This commit is contained in:
@@ -2,27 +2,49 @@ 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, MenuItemKind, PostData } from '../../../main/shared/electronApi';
|
||||
import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi';
|
||||
import { createAutoExpandController } from './menuAutoExpand';
|
||||
import {
|
||||
createMenuPageItemFromPost,
|
||||
filterPagePosts,
|
||||
getNextPickerIndex,
|
||||
isPickerCloseKey,
|
||||
isPickerFocusShortcut,
|
||||
} from './menuPagePicker';
|
||||
import { resolveInsertTarget } from './menuInsertTarget';
|
||||
import { filterPagePosts, isPickerCloseKey, isPickerFocusShortcut } from './menuPagePicker';
|
||||
import { applyTreeMove } from './menuTreeMove';
|
||||
import './MenuEditorView.css';
|
||||
|
||||
function createMenuItem(kind: MenuItemKind, title: string): MenuItemData {
|
||||
return {
|
||||
id: `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
title,
|
||||
kind,
|
||||
pageId: undefined,
|
||||
pageSlug: undefined,
|
||||
children: [],
|
||||
};
|
||||
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 {
|
||||
@@ -42,28 +64,6 @@ function findPathById(items: MenuItemData[], id: string, path: number[] = []): n
|
||||
return null;
|
||||
}
|
||||
|
||||
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 removeItemByPath(items: MenuItemData[], path: number[]): { next: MenuItemData[]; removed: MenuItemData | null } {
|
||||
if (path.length === 0) {
|
||||
return { next: items, removed: null };
|
||||
@@ -96,6 +96,28 @@ function removeItemByPath(items: MenuItemData[], path: number[]): { next: MenuIt
|
||||
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));
|
||||
@@ -129,19 +151,32 @@ function mapItems(items: MenuItemData[], mapper: (item: MenuItemData) => MenuIte
|
||||
});
|
||||
}
|
||||
|
||||
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 [showPagePicker, setShowPagePicker] = useState(false);
|
||||
const [pagePickerParentId, setPagePickerParentId] = useState<string | null>(null);
|
||||
const [pagePickerLoading, setPagePickerLoading] = useState(false);
|
||||
const [pagePickerQuery, setPagePickerQuery] = useState('');
|
||||
const [pagePickerPosts, setPagePickerPosts] = useState<PostData[]>([]);
|
||||
const [pagePickerActiveIndex, setPagePickerActiveIndex] = useState(-1);
|
||||
const pagePickerInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isLoadingPages, setIsLoadingPages] = useState(false);
|
||||
const [pagePosts, setPagePosts] = useState<PostData[]>([]);
|
||||
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState('');
|
||||
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||
const [toolbarTooltip, setToolbarTooltip] = useState<string>('');
|
||||
const [recentParentInsertId, setRecentParentInsertId] = useState<string | null>(null);
|
||||
const entryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const recentInsertTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoExpandController = useMemo(() => createAutoExpandController(450), []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -165,9 +200,54 @@ export const MenuEditorView: React.FC = () => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
autoExpandController.cancelAll();
|
||||
if (recentInsertTimerRef.current) {
|
||||
clearTimeout(recentInsertTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoExpandController]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingEntryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onWindowKeyDown = (event: KeyboardEvent): void => {
|
||||
if (isPickerFocusShortcut({ key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey })) {
|
||||
event.preventDefault();
|
||||
entryInputRef.current?.focus();
|
||||
entryInputRef.current?.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPickerCloseKey(event.key)) {
|
||||
event.preventDefault();
|
||||
setItems((previous) => {
|
||||
const path = findPathById(previous, editingEntryId);
|
||||
if (!path) {
|
||||
return previous;
|
||||
}
|
||||
return removeItemByPath(previous, path).next;
|
||||
});
|
||||
setEditingEntryId(null);
|
||||
setEditingText('');
|
||||
setSelectedPageId(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onWindowKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onWindowKeyDown);
|
||||
};
|
||||
}, [editingEntryId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingEntryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
entryInputRef.current?.focus();
|
||||
}, [editingEntryId]);
|
||||
|
||||
const selectedPath = useMemo(() => {
|
||||
if (!selectedId) {
|
||||
return null;
|
||||
@@ -175,159 +255,160 @@ export const MenuEditorView: React.FC = () => {
|
||||
return findPathById(items, selectedId);
|
||||
}, [items, selectedId]);
|
||||
|
||||
const selectedItem = useMemo(() => {
|
||||
if (!selectedPath || selectedPath.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentItems = items;
|
||||
let current: MenuItemData | null = null;
|
||||
for (const segment of selectedPath) {
|
||||
current = currentItems[segment] || null;
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
currentItems = current.children;
|
||||
}
|
||||
|
||||
return current;
|
||||
}, [items, selectedPath]);
|
||||
|
||||
const filteredPagePosts = useMemo(() => {
|
||||
return filterPagePosts(pagePickerPosts, pagePickerQuery);
|
||||
}, [pagePickerPosts, pagePickerQuery]);
|
||||
if (!editingEntryId) {
|
||||
return [];
|
||||
}
|
||||
return filterPagePosts(pagePosts, editingText);
|
||||
}, [editingEntryId, pagePosts, editingText]);
|
||||
|
||||
const replaceSelected = (updater: (item: MenuItemData) => MenuItemData): void => {
|
||||
if (!selectedId) {
|
||||
const ensurePagePostsLoaded = async (): Promise<void> => {
|
||||
if (pagePosts.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((previous) => mapItems(previous, (item) => (item.id === selectedId ? updater(item) : item)));
|
||||
};
|
||||
|
||||
const insertItem = (previous: MenuItemData[], node: MenuItemData, parentId: string | null): MenuItemData[] => {
|
||||
if (!parentId) {
|
||||
return [...previous, node];
|
||||
}
|
||||
|
||||
return mapItems(previous, (item) => {
|
||||
if (item.id !== parentId) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
children: [...item.children, node],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const closePagePicker = (): void => {
|
||||
setShowPagePicker(false);
|
||||
setPagePickerParentId(null);
|
||||
setPagePickerQuery('');
|
||||
setPagePickerActiveIndex(-1);
|
||||
};
|
||||
|
||||
const openPagePicker = async (parentId: string | null): Promise<void> => {
|
||||
setShowPagePicker(true);
|
||||
setPagePickerParentId(parentId);
|
||||
setPagePickerQuery('');
|
||||
setPagePickerActiveIndex(-1);
|
||||
setPagePickerLoading(true);
|
||||
setIsLoadingPages(true);
|
||||
try {
|
||||
const posts = await window.electronAPI.posts.filter({ categories: ['page'] });
|
||||
setPagePickerPosts(posts);
|
||||
setPagePosts(posts);
|
||||
} catch (error) {
|
||||
console.error('Failed to load page posts:', error);
|
||||
showToast.error(tr('menuEditor.pagePicker.loadError'));
|
||||
setPagePickerPosts([]);
|
||||
setPagePosts([]);
|
||||
} finally {
|
||||
setPagePickerLoading(false);
|
||||
setIsLoadingPages(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectPageForMenu = (post: PostData): void => {
|
||||
const node = createMenuPageItemFromPost(post);
|
||||
setItems((previous) => insertItem(previous, node, pagePickerParentId));
|
||||
setSelectedId(node.id);
|
||||
closePagePicker();
|
||||
const finalizeEntry = (): void => {
|
||||
if (!editingEntryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedPage = selectedPageId ? pagePosts.find((post) => post.id === selectedPageId) : null;
|
||||
const trimmed = editingText.trim();
|
||||
|
||||
if (selectedPage) {
|
||||
setItems((previous) => mapItems(previous, (item) => {
|
||||
if (item.id !== editingEntryId) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
title: selectedPage.title,
|
||||
kind: 'page',
|
||||
pageId: selectedPage.id,
|
||||
pageSlug: selectedPage.slug,
|
||||
};
|
||||
}));
|
||||
} else if (trimmed) {
|
||||
setItems((previous) => mapItems(previous, (item) => {
|
||||
if (item.id !== editingEntryId) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
title: trimmed,
|
||||
kind: 'submenu',
|
||||
pageId: undefined,
|
||||
pageSlug: undefined,
|
||||
};
|
||||
}));
|
||||
} else {
|
||||
setItems((previous) => {
|
||||
const path = findPathById(previous, editingEntryId);
|
||||
if (!path) {
|
||||
return previous;
|
||||
}
|
||||
return removeItemByPath(previous, path).next;
|
||||
});
|
||||
setSelectedId(null);
|
||||
}
|
||||
|
||||
setEditingEntryId(null);
|
||||
setEditingText('');
|
||||
setSelectedPageId(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPagePicker) {
|
||||
const finalizeEntryWithPage = (post: PostData): void => {
|
||||
if (!editingEntryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filteredPagePosts.length === 0) {
|
||||
setPagePickerActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
setPagePickerActiveIndex((previous) => {
|
||||
if (previous < 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(previous, filteredPagePosts.length - 1);
|
||||
});
|
||||
}, [filteredPagePosts, showPagePicker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPagePicker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onWindowKeyDown = (event: KeyboardEvent): void => {
|
||||
if (isPickerFocusShortcut({ key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey })) {
|
||||
event.preventDefault();
|
||||
pagePickerInputRef.current?.focus();
|
||||
pagePickerInputRef.current?.select();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onWindowKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onWindowKeyDown);
|
||||
};
|
||||
}, [showPagePicker]);
|
||||
|
||||
const addRootItem = (kind: MenuItemKind): void => {
|
||||
if (kind === 'page') {
|
||||
void openPagePicker(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = kind === 'page' ? tr('menuEditor.newPage') : tr('menuEditor.newSubmenu');
|
||||
const node = createMenuItem(kind, title);
|
||||
setItems((previous) => [...previous, node]);
|
||||
setSelectedId(node.id);
|
||||
};
|
||||
|
||||
const addChildItem = (kind: MenuItemKind): void => {
|
||||
if (!selectedId) {
|
||||
addRootItem(kind);
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'page') {
|
||||
void openPagePicker(selectedId);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = kind === 'page' ? tr('menuEditor.newPage') : tr('menuEditor.newSubmenu');
|
||||
const node = createMenuItem(kind, title);
|
||||
|
||||
setItems((previous) => mapItems(previous, (item) => {
|
||||
if (item.id !== selectedId) {
|
||||
if (item.id !== editingEntryId) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
children: [...item.children, node],
|
||||
title: post.title,
|
||||
kind: 'page',
|
||||
pageId: post.id,
|
||||
pageSlug: post.slug,
|
||||
};
|
||||
}));
|
||||
setSelectedId(node.id);
|
||||
|
||||
setEditingEntryId(null);
|
||||
setEditingText('');
|
||||
setSelectedPageId(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);
|
||||
setEditingText('');
|
||||
setSelectedPageId(null);
|
||||
};
|
||||
|
||||
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 => {
|
||||
@@ -402,22 +483,13 @@ export const MenuEditorView: React.FC = () => {
|
||||
const removed = removeItemByPath(previous, selectedPath);
|
||||
return removed.next;
|
||||
});
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
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);
|
||||
if (editingEntryId === selectedId) {
|
||||
setEditingEntryId(null);
|
||||
setEditingText('');
|
||||
setSelectedPageId(null);
|
||||
}
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -435,36 +507,71 @@ export const MenuEditorView: React.FC = () => {
|
||||
<div className="menu-editor-main">
|
||||
<div className="menu-editor-tree-wrap">
|
||||
<div className="menu-editor-toolbar" role="toolbar" aria-label={tr('menuEditor.title')}>
|
||||
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addPage')} aria-label={tr('menuEditor.addPage')} onClick={() => addRootItem('page')}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M3 2h6l4 4v8H3V2zm6 1.5V6h2.5L9 3.5zM7 8V6h2v2h2v2H9v2H7v-2H5V8h2z" /></svg>
|
||||
</button>
|
||||
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addSubmenu')} aria-label={tr('menuEditor.addSubmenu')} onClick={() => addRootItem('submenu')}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h8v2H2V3zm0 4h8v2H2V7zm0 4h8v2H2v-2zm9-8h3v3h-1V4h-2V3zm2 4h1v6h-6v-1h5V7z" /></svg>
|
||||
</button>
|
||||
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addChildPage')} aria-label={tr('menuEditor.addChildPage')} onClick={() => addChildItem('page')}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h5v2H4v6h3v2H2V3zm6 5V6h2v2h2v2h-2v2H8v-2H6V8h2z" /></svg>
|
||||
</button>
|
||||
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addChildSubmenu')} aria-label={tr('menuEditor.addChildSubmenu')} onClick={() => addChildItem('submenu')}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h5v2H4v6h3v2H2V3zm5 2h7v2H7V5zm3 3h4v2h-4V8zm0 3h4v2h-4v-2z" /></svg>
|
||||
</button>
|
||||
<button type="button" className="menu-editor-tool" title={tr('menuEditor.moveUp')} aria-label={tr('menuEditor.moveUp')} onClick={() => moveSelected('up')} disabled={!selectedPath}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
|
||||
</button>
|
||||
<button type="button" className="menu-editor-tool" title={tr('menuEditor.moveDown')} aria-label={tr('menuEditor.moveDown')} onClick={() => moveSelected('down')} disabled={!selectedPath}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
|
||||
</button>
|
||||
<button type="button" className="menu-editor-tool" title={tr('menuEditor.indent')} aria-label={tr('menuEditor.indent')} onClick={indentSelected} disabled={!selectedPath || selectedPath[selectedPath.length - 1] === 0}>
|
||||
<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>
|
||||
</button>
|
||||
<button type="button" className="menu-editor-tool" title={tr('menuEditor.unindent')} aria-label={tr('menuEditor.unindent')} onClick={unindentSelected} disabled={!selectedPath || selectedPath.length < 2}>
|
||||
<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>
|
||||
</button>
|
||||
<button type="button" className="menu-editor-tool" title={tr('menuEditor.delete')} aria-label={tr('menuEditor.delete')} onClick={deleteSelected} disabled={!selectedPath}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
|
||||
</button>
|
||||
<button type="button" className="menu-editor-tool" title={tr('menuEditor.save')} aria-label={tr('menuEditor.save')} onClick={() => void save()} disabled={isSaving}>
|
||||
<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>
|
||||
</button>
|
||||
</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 ? (
|
||||
@@ -472,9 +579,9 @@ export const MenuEditorView: React.FC = () => {
|
||||
) : (
|
||||
<Tree<MenuItemData>
|
||||
data={items}
|
||||
width={720}
|
||||
height={420}
|
||||
rowHeight={30}
|
||||
width="100%"
|
||||
height={editingEntryId ? 320 : 460}
|
||||
rowHeight={32}
|
||||
indent={20}
|
||||
openByDefault
|
||||
disableEdit
|
||||
@@ -493,7 +600,7 @@ export const MenuEditorView: React.FC = () => {
|
||||
{({ node, style, tree }) => (
|
||||
<div
|
||||
style={style}
|
||||
className={`menu-editor-row ${selectedId === node.data.id ? 'is-selected' : ''}`}
|
||||
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) {
|
||||
@@ -509,137 +616,81 @@ export const MenuEditorView: React.FC = () => {
|
||||
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">{node.data.title}</span>
|
||||
<>
|
||||
<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">{node.data.title}</span>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Tree>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="menu-editor-details">
|
||||
<h3>{tr('menuEditor.details')}</h3>
|
||||
{!selectedItem ? (
|
||||
<p>{tr('menuEditor.selectItem')}</p>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
<span>{tr('menuEditor.field.title')}</span>
|
||||
{editingEntryId && (
|
||||
<div className="menu-editor-inline-search">
|
||||
<div className="menu-editor-entry-editor">
|
||||
<input
|
||||
ref={entryInputRef}
|
||||
type="text"
|
||||
value={selectedItem.title}
|
||||
className="menu-editor-inline-input"
|
||||
value={editingText}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
replaceSelected((item) => ({ ...item, title: value }));
|
||||
setEditingText(event.target.value);
|
||||
setSelectedPageId(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
finalizeEntry();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setItems((previous) => {
|
||||
const path = findPathById(previous, editingEntryId);
|
||||
if (!path) {
|
||||
return previous;
|
||||
}
|
||||
return removeItemByPath(previous, path).next;
|
||||
});
|
||||
setEditingEntryId(null);
|
||||
setEditingText('');
|
||||
setSelectedPageId(null);
|
||||
}
|
||||
}}
|
||||
placeholder={tr('menuEditor.newEntryPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>{tr('menuEditor.field.type')}</span>
|
||||
<select
|
||||
value={selectedItem.kind}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as MenuItemKind;
|
||||
replaceSelected((item) => ({ ...item, kind: value }));
|
||||
}}
|
||||
>
|
||||
<option value="page">{tr('menuEditor.type.page')}</option>
|
||||
<option value="submenu">{tr('menuEditor.type.submenu')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedItem.kind === 'page' && (
|
||||
<>
|
||||
<label>
|
||||
<span>{tr('menuEditor.field.pageSlug')}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedItem.pageSlug || ''}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
replaceSelected((item) => ({ ...item, pageSlug: value || undefined }));
|
||||
</div>
|
||||
<div className="menu-editor-inline-search-head">
|
||||
<strong>{tr('menuEditor.pagePicker.title')}</strong>
|
||||
<span>{tr('menuEditor.createHint')}</span>
|
||||
</div>
|
||||
{isLoadingPages ? (
|
||||
<div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.loading')}</div>
|
||||
) : filteredPagePosts.length === 0 ? (
|
||||
<div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.empty')}</div>
|
||||
) : (
|
||||
<div className="menu-editor-picker-list">
|
||||
{filteredPagePosts.map((post) => (
|
||||
<button
|
||||
key={post.id}
|
||||
type="button"
|
||||
className={`menu-editor-picker-item ${selectedPageId === post.id ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedPageId(post.id);
|
||||
setEditingText(post.title);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>{tr('menuEditor.field.pageId')}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedItem.pageId || ''}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
replaceSelected((item) => ({ ...item, pageId: value || undefined }));
|
||||
onDoubleClick={() => {
|
||||
finalizeEntryWithPage(post);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
>
|
||||
<span>{post.title}</span>
|
||||
<small>/{post.slug}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPagePicker && (
|
||||
<div className="menu-editor-picker-backdrop" onClick={closePagePicker}>
|
||||
<div className="menu-editor-picker" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="menu-editor-picker-header">
|
||||
<h3>{tr('menuEditor.pagePicker.title')}</h3>
|
||||
<button type="button" onClick={closePagePicker}>{tr('common.cancel')}</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={pagePickerInputRef}
|
||||
type="text"
|
||||
value={pagePickerQuery}
|
||||
onChange={(event) => setPagePickerQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (isPickerCloseKey(event.key)) {
|
||||
event.preventDefault();
|
||||
closePagePicker();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setPagePickerActiveIndex((previous) => getNextPickerIndex(previous, event.key, filteredPagePosts.length));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && pagePickerActiveIndex >= 0 && pagePickerActiveIndex < filteredPagePosts.length) {
|
||||
event.preventDefault();
|
||||
selectPageForMenu(filteredPagePosts[pagePickerActiveIndex]);
|
||||
}
|
||||
}}
|
||||
placeholder={tr('menuEditor.pagePicker.searchPlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{pagePickerLoading ? (
|
||||
<div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.loading')}</div>
|
||||
) : filteredPagePosts.length === 0 ? (
|
||||
<div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.empty')}</div>
|
||||
) : (
|
||||
<div className="menu-editor-picker-list">
|
||||
{filteredPagePosts.map((post) => (
|
||||
<button
|
||||
key={post.id}
|
||||
type="button"
|
||||
className={`menu-editor-picker-item ${filteredPagePosts[pagePickerActiveIndex]?.id === post.id ? 'is-active' : ''}`}
|
||||
onClick={() => selectPageForMenu(post)}
|
||||
onMouseEnter={() => {
|
||||
const nextIndex = filteredPagePosts.findIndex((candidate) => candidate.id === post.id);
|
||||
setPagePickerActiveIndex(nextIndex);
|
||||
}}
|
||||
>
|
||||
<span>{post.title}</span>
|
||||
<small>/{post.slug}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user