feat: rudimentary menu working now

This commit is contained in:
2026-02-21 20:49:10 +01:00
parent 76c3a8368e
commit 0860dbe557
11 changed files with 724 additions and 352 deletions

View File

@@ -29,15 +29,12 @@
} }
.menu-editor-main { .menu-editor-main {
display: grid; display: block;
grid-template-columns: minmax(480px, 1fr) minmax(280px, 340px);
gap: 0.75rem;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
} }
.menu-editor-tree-wrap, .menu-editor-tree-wrap {
.menu-editor-details {
border: 1px solid var(--vscode-panel-border); border: 1px solid var(--vscode-panel-border);
border-radius: 6px; border-radius: 6px;
background: var(--vscode-editor-background); background: var(--vscode-editor-background);
@@ -68,6 +65,26 @@
padding: 0; padding: 0;
} }
.menu-editor-tool-wrap {
display: inline-flex;
}
.menu-editor-toolbar-tooltip {
margin-left: auto;
max-width: 320px;
min-width: 120px;
border: 1px solid var(--vscode-editorHoverWidget-border);
border-radius: 4px;
background: var(--vscode-editorHoverWidget-background);
color: var(--vscode-editorHoverWidget-foreground);
font-size: 0.75rem;
line-height: 1.2;
padding: 0.25rem 0.45rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-editor-tool:hover:not(:disabled) { .menu-editor-tool:hover:not(:disabled) {
background: var(--vscode-toolbar-hoverBackground); background: var(--vscode-toolbar-hoverBackground);
border-color: var(--vscode-panel-border); border-color: var(--vscode-panel-border);
@@ -92,6 +109,11 @@
color: var(--vscode-list-activeSelectionForeground); color: var(--vscode-list-activeSelectionForeground);
} }
.menu-editor-row.is-parent-target {
box-shadow: inset 0 0 0 1px var(--vscode-focusBorder);
background: var(--vscode-list-hoverBackground);
}
.menu-editor-row-kind { .menu-editor-row-kind {
font-size: 0.75rem; font-size: 0.75rem;
opacity: 0.85; opacity: 0.85;
@@ -103,20 +125,45 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.menu-editor-details { .menu-editor-inline-input {
display: flex; width: 100%;
flex-direction: column; border: 1px solid var(--vscode-focusBorder);
gap: 0.75rem; border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 0.15rem 0.4rem;
min-height: 1.6rem;
} }
.menu-editor-details h3 { .menu-editor-inline-search {
margin: 0; margin-top: 0.5rem;
} border-top: 1px solid var(--vscode-panel-border);
padding-top: 0.5rem;
.menu-editor-details label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.35rem;
max-height: 18rem;
overflow: hidden;
}
.menu-editor-entry-editor {
display: block;
}
.menu-editor-inline-search-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.menu-editor-inline-search-head strong {
font-size: 0.8rem;
}
.menu-editor-inline-search-head span {
color: var(--vscode-descriptionForeground);
font-size: 0.75rem;
} }
.menu-editor-picker-backdrop { .menu-editor-picker-backdrop {
@@ -154,7 +201,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.35rem;
overflow: auto; max-height: 16rem;
overflow-y: auto;
} }
.menu-editor-picker-item { .menu-editor-picker-item {

View File

@@ -2,27 +2,49 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Tree } from 'react-arborist'; import { Tree } from 'react-arborist';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
import { showToast } from '../Toast'; 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 { createAutoExpandController } from './menuAutoExpand';
import { import { resolveInsertTarget } from './menuInsertTarget';
createMenuPageItemFromPost, import { filterPagePosts, isPickerCloseKey, isPickerFocusShortcut } from './menuPagePicker';
filterPagePosts,
getNextPickerIndex,
isPickerCloseKey,
isPickerFocusShortcut,
} from './menuPagePicker';
import { applyTreeMove } from './menuTreeMove'; import { applyTreeMove } from './menuTreeMove';
import './MenuEditorView.css'; import './MenuEditorView.css';
function createMenuItem(kind: MenuItemKind, title: string): MenuItemData { interface ToolButtonProps {
return { label: string;
id: `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, disabled?: boolean;
title, onClick: () => void;
kind, onShowTooltip: (label: string) => void;
pageId: undefined, onHideTooltip: () => void;
pageSlug: undefined, children: React.ReactNode;
children: [], }
};
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 { 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; 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 } { function removeItemByPath(items: MenuItemData[], path: number[]): { next: MenuItemData[]; removed: MenuItemData | null } {
if (path.length === 0) { if (path.length === 0) {
return { next: items, removed: null }; return { next: items, removed: null };
@@ -96,6 +96,28 @@ function removeItemByPath(items: MenuItemData[], path: number[]): { next: MenuIt
return { next, removed: nested.removed }; 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[] { function insertItemAtPath(items: MenuItemData[], parentPath: number[], index: number, node: MenuItemData): MenuItemData[] {
if (parentPath.length === 0) { if (parentPath.length === 0) {
const boundedIndex = Math.max(0, Math.min(index, items.length)); 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 = () => { export const MenuEditorView: React.FC = () => {
const { t: tr } = useI18n(); const { t: tr } = useI18n();
const [items, setItems] = useState<MenuItemData[]>([]); const [items, setItems] = useState<MenuItemData[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [showPagePicker, setShowPagePicker] = useState(false); const [isLoadingPages, setIsLoadingPages] = useState(false);
const [pagePickerParentId, setPagePickerParentId] = useState<string | null>(null); const [pagePosts, setPagePosts] = useState<PostData[]>([]);
const [pagePickerLoading, setPagePickerLoading] = useState(false); const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
const [pagePickerQuery, setPagePickerQuery] = useState(''); const [editingText, setEditingText] = useState('');
const [pagePickerPosts, setPagePickerPosts] = useState<PostData[]>([]); const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [pagePickerActiveIndex, setPagePickerActiveIndex] = useState(-1); const [toolbarTooltip, setToolbarTooltip] = useState<string>('');
const pagePickerInputRef = useRef<HTMLInputElement | null>(null); 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), []); const autoExpandController = useMemo(() => createAutoExpandController(450), []);
useEffect(() => { useEffect(() => {
@@ -165,9 +200,54 @@ export const MenuEditorView: React.FC = () => {
useEffect(() => { useEffect(() => {
return () => { return () => {
autoExpandController.cancelAll(); autoExpandController.cancelAll();
if (recentInsertTimerRef.current) {
clearTimeout(recentInsertTimerRef.current);
}
}; };
}, [autoExpandController]); }, [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(() => { const selectedPath = useMemo(() => {
if (!selectedId) { if (!selectedId) {
return null; return null;
@@ -175,159 +255,160 @@ export const MenuEditorView: React.FC = () => {
return findPathById(items, selectedId); return findPathById(items, selectedId);
}, [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(() => { const filteredPagePosts = useMemo(() => {
return filterPagePosts(pagePickerPosts, pagePickerQuery); if (!editingEntryId) {
}, [pagePickerPosts, pagePickerQuery]); return [];
}
return filterPagePosts(pagePosts, editingText);
}, [editingEntryId, pagePosts, editingText]);
const replaceSelected = (updater: (item: MenuItemData) => MenuItemData): void => { const ensurePagePostsLoaded = async (): Promise<void> => {
if (!selectedId) { if (pagePosts.length > 0) {
return; return;
} }
setItems((previous) => mapItems(previous, (item) => (item.id === selectedId ? updater(item) : item))); setIsLoadingPages(true);
};
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);
try { try {
const posts = await window.electronAPI.posts.filter({ categories: ['page'] }); const posts = await window.electronAPI.posts.filter({ categories: ['page'] });
setPagePickerPosts(posts); setPagePosts(posts);
} catch (error) { } catch (error) {
console.error('Failed to load page posts:', error); console.error('Failed to load page posts:', error);
showToast.error(tr('menuEditor.pagePicker.loadError')); showToast.error(tr('menuEditor.pagePicker.loadError'));
setPagePickerPosts([]); setPagePosts([]);
} finally { } finally {
setPagePickerLoading(false); setIsLoadingPages(false);
} }
}; };
const selectPageForMenu = (post: PostData): void => { const finalizeEntry = (): void => {
const node = createMenuPageItemFromPost(post); if (!editingEntryId) {
setItems((previous) => insertItem(previous, node, pagePickerParentId)); return;
setSelectedId(node.id); }
closePagePicker();
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(() => { const finalizeEntryWithPage = (post: PostData): void => {
if (!showPagePicker) { if (!editingEntryId) {
return; 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) => { setItems((previous) => mapItems(previous, (item) => {
if (item.id !== selectedId) { if (item.id !== editingEntryId) {
return item; return item;
} }
return { return {
...item, ...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 => { const moveSelected = (direction: 'up' | 'down'): void => {
@@ -402,22 +483,13 @@ export const MenuEditorView: React.FC = () => {
const removed = removeItemByPath(previous, selectedPath); const removed = removeItemByPath(previous, selectedPath);
return removed.next; return removed.next;
}); });
setSelectedId(null);
};
const save = async (): Promise<void> => { if (editingEntryId === selectedId) {
setIsSaving(true); setEditingEntryId(null);
try { setEditingText('');
const payload: MenuDocument = { items }; setSelectedPageId(null);
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);
} }
setSelectedId(null);
}; };
return ( return (
@@ -435,36 +507,71 @@ export const MenuEditorView: React.FC = () => {
<div className="menu-editor-main"> <div className="menu-editor-main">
<div className="menu-editor-tree-wrap"> <div className="menu-editor-tree-wrap">
<div className="menu-editor-toolbar" role="toolbar" aria-label={tr('menuEditor.title')}> <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')}> <ToolButton
<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> label={tr('menuEditor.addEntry')}
</button> onClick={() => void startCreateEntry()}
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addSubmenu')} aria-label={tr('menuEditor.addSubmenu')} onClick={() => addRootItem('submenu')}> onShowTooltip={setToolbarTooltip}
<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> onHideTooltip={() => setToolbarTooltip('')}
</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="M7 2h2v5h5v2H9v5H7V9H2V7h5V2z" /></svg>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h5v2H4v6h3v2H2V3zm6 5V6h2v2h2v2h-2v2H8v-2H6V8h2z" /></svg> </ToolButton>
</button> <ToolButton
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addChildSubmenu')} aria-label={tr('menuEditor.addChildSubmenu')} onClick={() => addChildItem('submenu')}> label={tr('menuEditor.save')}
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h5v2H4v6h3v2H2V3zm5 2h7v2H7V5zm3 3h4v2h-4V8zm0 3h4v2h-4v-2z" /></svg> onClick={() => void save()}
</button> disabled={isSaving}
<button type="button" className="menu-editor-tool" title={tr('menuEditor.moveUp')} aria-label={tr('menuEditor.moveUp')} onClick={() => moveSelected('up')} disabled={!selectedPath}> onShowTooltip={setToolbarTooltip}
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg> onHideTooltip={() => setToolbarTooltip('')}
</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}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg> <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> </div>
{items.length === 0 ? ( {items.length === 0 ? (
@@ -472,9 +579,9 @@ export const MenuEditorView: React.FC = () => {
) : ( ) : (
<Tree<MenuItemData> <Tree<MenuItemData>
data={items} data={items}
width={720} width="100%"
height={420} height={editingEntryId ? 320 : 460}
rowHeight={30} rowHeight={32}
indent={20} indent={20}
openByDefault openByDefault
disableEdit disableEdit
@@ -493,7 +600,7 @@ export const MenuEditorView: React.FC = () => {
{({ node, style, tree }) => ( {({ node, style, tree }) => (
<div <div
style={style} 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)} onClick={() => setSelectedId(node.data.id)}
onMouseEnter={() => { onMouseEnter={() => {
if (!tree.dragNode || !node.isInternal || node.isOpen) { if (!tree.dragNode || !node.isInternal || node.isOpen) {
@@ -509,137 +616,81 @@ export const MenuEditorView: React.FC = () => {
autoExpandController.cancel(node.id); autoExpandController.cancel(node.id);
}} }}
> >
<span className="menu-editor-row-kind"> <>
{node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')} <span className="menu-editor-row-kind">
</span> {node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')}
<span className="menu-editor-row-title">{node.data.title}</span> </span>
<span className="menu-editor-row-title">{node.data.title}</span>
</>
</div> </div>
)} )}
</Tree> </Tree>
)} )}
</div>
<div className="menu-editor-details"> {editingEntryId && (
<h3>{tr('menuEditor.details')}</h3> <div className="menu-editor-inline-search">
{!selectedItem ? ( <div className="menu-editor-entry-editor">
<p>{tr('menuEditor.selectItem')}</p>
) : (
<>
<label>
<span>{tr('menuEditor.field.title')}</span>
<input <input
ref={entryInputRef}
type="text" type="text"
value={selectedItem.title} className="menu-editor-inline-input"
value={editingText}
onChange={(event) => { onChange={(event) => {
const value = event.target.value; setEditingText(event.target.value);
replaceSelected((item) => ({ ...item, title: 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> </div>
<div className="menu-editor-inline-search-head">
<label> <strong>{tr('menuEditor.pagePicker.title')}</strong>
<span>{tr('menuEditor.field.type')}</span> <span>{tr('menuEditor.createHint')}</span>
<select </div>
value={selectedItem.kind} {isLoadingPages ? (
onChange={(event) => { <div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.loading')}</div>
const value = event.target.value as MenuItemKind; ) : filteredPagePosts.length === 0 ? (
replaceSelected((item) => ({ ...item, kind: value })); <div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.empty')}</div>
}} ) : (
> <div className="menu-editor-picker-list">
<option value="page">{tr('menuEditor.type.page')}</option> {filteredPagePosts.map((post) => (
<option value="submenu">{tr('menuEditor.type.submenu')}</option> <button
</select> key={post.id}
</label> type="button"
className={`menu-editor-picker-item ${selectedPageId === post.id ? 'is-active' : ''}`}
{selectedItem.kind === 'page' && ( onClick={() => {
<> setSelectedPageId(post.id);
<label> setEditingText(post.title);
<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 }));
}} }}
/> onDoubleClick={() => {
</label> finalizeEntryWithPage(post);
<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 }));
}} }}
/> >
</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>
)} )}
</div> </div>

View File

@@ -0,0 +1,70 @@
import type { MenuItemData } from '../../../main/shared/electronApi';
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 getNodeByPath(items: MenuItemData[], path: number[]): MenuItemData | null {
let currentItems = items;
let current: MenuItemData | null = null;
for (const segment of path) {
current = currentItems[segment] || null;
if (!current) {
return null;
}
currentItems = current.children;
}
return current;
}
export interface InsertTarget {
parentPath: number[];
index: number;
}
export function resolveInsertTarget(items: MenuItemData[], selectedId: string | null): InsertTarget {
if (!selectedId) {
return {
parentPath: [],
index: items.length,
};
}
const path = findPathById(items, selectedId);
if (!path || path.length === 0) {
return {
parentPath: [],
index: items.length,
};
}
const selectedNode = getNodeByPath(items, path);
if (selectedNode?.kind === 'submenu') {
return {
parentPath: path,
index: 0,
};
}
const parentPath = path.slice(0, -1);
const index = path[path.length - 1] + 1;
return {
parentPath,
index,
};
}

View File

@@ -50,6 +50,9 @@
"menuEditor.saving": "Speichern...", "menuEditor.saving": "Speichern...",
"menuEditor.saved": "Blog-Menü gespeichert", "menuEditor.saved": "Blog-Menü gespeichert",
"menuEditor.saveFailed": "Blog-Menü konnte nicht gespeichert werden", "menuEditor.saveFailed": "Blog-Menü konnte nicht gespeichert werden",
"menuEditor.addEntry": "Eintrag hinzufügen",
"menuEditor.newEntryPlaceholder": "Seitentitel oder Untermenü-Bezeichnung eingeben",
"menuEditor.createHint": "Unten eine Seite wählen oder Enter drücken, um ein Untermenü zu erstellen",
"menuEditor.pagePicker.title": "Seite auswählen", "menuEditor.pagePicker.title": "Seite auswählen",
"menuEditor.pagePicker.searchPlaceholder": "Seiten nach Titel oder Slug durchsuchen...", "menuEditor.pagePicker.searchPlaceholder": "Seiten nach Titel oder Slug durchsuchen...",
"menuEditor.pagePicker.loading": "Seiten werden geladen...", "menuEditor.pagePicker.loading": "Seiten werden geladen...",

View File

@@ -50,6 +50,9 @@
"menuEditor.saving": "Saving...", "menuEditor.saving": "Saving...",
"menuEditor.saved": "Blog menu saved", "menuEditor.saved": "Blog menu saved",
"menuEditor.saveFailed": "Failed to save blog menu", "menuEditor.saveFailed": "Failed to save blog menu",
"menuEditor.addEntry": "Add Entry",
"menuEditor.newEntryPlaceholder": "Type a page title or submenu label",
"menuEditor.createHint": "Select a page below or press Enter to create a submenu",
"menuEditor.pagePicker.title": "Select Page", "menuEditor.pagePicker.title": "Select Page",
"menuEditor.pagePicker.searchPlaceholder": "Search pages by title or slug...", "menuEditor.pagePicker.searchPlaceholder": "Search pages by title or slug...",
"menuEditor.pagePicker.loading": "Loading pages...", "menuEditor.pagePicker.loading": "Loading pages...",

View File

@@ -50,6 +50,9 @@
"menuEditor.saving": "Guardando...", "menuEditor.saving": "Guardando...",
"menuEditor.saved": "Menú del blog guardado", "menuEditor.saved": "Menú del blog guardado",
"menuEditor.saveFailed": "No se pudo guardar el menú del blog", "menuEditor.saveFailed": "No se pudo guardar el menú del blog",
"menuEditor.addEntry": "Añadir entrada",
"menuEditor.newEntryPlaceholder": "Escribe un título de página o etiqueta de submenú",
"menuEditor.createHint": "Selecciona una página abajo o pulsa Enter para crear un submenú",
"menuEditor.pagePicker.title": "Seleccionar página", "menuEditor.pagePicker.title": "Seleccionar página",
"menuEditor.pagePicker.searchPlaceholder": "Buscar páginas por título o slug...", "menuEditor.pagePicker.searchPlaceholder": "Buscar páginas por título o slug...",
"menuEditor.pagePicker.loading": "Cargando páginas...", "menuEditor.pagePicker.loading": "Cargando páginas...",

View File

@@ -50,6 +50,9 @@
"menuEditor.saving": "Enregistrement...", "menuEditor.saving": "Enregistrement...",
"menuEditor.saved": "Menu du blog enregistré", "menuEditor.saved": "Menu du blog enregistré",
"menuEditor.saveFailed": "Impossible denregistrer le menu du blog", "menuEditor.saveFailed": "Impossible denregistrer le menu du blog",
"menuEditor.addEntry": "Ajouter une entrée",
"menuEditor.newEntryPlaceholder": "Saisissez un titre de page ou un libellé de sous-menu",
"menuEditor.createHint": "Sélectionnez une page ci-dessous ou appuyez sur Entrée pour créer un sous-menu",
"menuEditor.pagePicker.title": "Sélectionner une page", "menuEditor.pagePicker.title": "Sélectionner une page",
"menuEditor.pagePicker.searchPlaceholder": "Rechercher des pages par titre ou slug...", "menuEditor.pagePicker.searchPlaceholder": "Rechercher des pages par titre ou slug...",
"menuEditor.pagePicker.loading": "Chargement des pages...", "menuEditor.pagePicker.loading": "Chargement des pages...",

View File

@@ -50,6 +50,9 @@
"menuEditor.saving": "Salvataggio...", "menuEditor.saving": "Salvataggio...",
"menuEditor.saved": "Menu blog salvato", "menuEditor.saved": "Menu blog salvato",
"menuEditor.saveFailed": "Impossibile salvare il menu blog", "menuEditor.saveFailed": "Impossibile salvare il menu blog",
"menuEditor.addEntry": "Aggiungi voce",
"menuEditor.newEntryPlaceholder": "Inserisci un titolo pagina o etichetta sottomenu",
"menuEditor.createHint": "Seleziona una pagina qui sotto o premi Invio per creare un sottomenu",
"menuEditor.pagePicker.title": "Seleziona pagina", "menuEditor.pagePicker.title": "Seleziona pagina",
"menuEditor.pagePicker.searchPlaceholder": "Cerca pagine per titolo o slug...", "menuEditor.pagePicker.searchPlaceholder": "Cerca pagine per titolo o slug...",
"menuEditor.pagePicker.loading": "Caricamento pagine...", "menuEditor.pagePicker.loading": "Caricamento pagine...",

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('MenuEditorView styles', () => {
const cssPath = path.resolve(
__dirname,
'../../../src/renderer/components/MenuEditorView/MenuEditorView.css'
);
it('makes page selector results scrollable with bounded height', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/\.menu-editor-picker-list\s*\{[^}]*max-height:\s*[^;]+;[^}]*overflow-y:\s*auto;[^}]*\}/s);
});
it('bounds the inline selector area so it does not spill beyond the editor viewport', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/\.menu-editor-inline-search\s*\{[^}]*max-height:\s*[^;]+;[^}]*overflow:\s*hidden;[^}]*\}/s);
});
});

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { MenuEditorView } from '../../../src/renderer/components/MenuEditorView/MenuEditorView';
describe('MenuEditorView entry editor', () => {
beforeEach(() => {
vi.clearAllMocks();
(window as any).addEventListener = vi.fn();
(window as any).removeEventListener = vi.fn();
(window as any).electronAPI = {
...(window as any).electronAPI,
menu: {
get: vi.fn().mockResolvedValue({
items: [
{
id: 'root-page',
title: 'Home',
kind: 'page',
children: [],
},
],
}),
save: vi.fn().mockResolvedValue({ items: [] }),
},
posts: {
...(window as any).electronAPI?.posts,
filter: vi.fn().mockResolvedValue([
{
id: 'page-about',
projectId: 'project-1',
title: 'About',
slug: 'about',
content: '',
status: 'published',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: [],
categories: ['page'],
},
]),
},
};
});
it('uses a standalone input editor and keeps focus while typing multiple characters', async () => {
const { container } = render(<MenuEditorView />);
const addButton = await screen.findByRole('button', { name: /add entry/i });
fireEvent.click(addButton);
const input = await screen.findByPlaceholderText(/type a page title or submenu label/i);
expect(input.closest('.menu-editor-entry-editor')).not.toBeNull();
fireEvent.change(input, { target: { value: 'a' } });
fireEvent.change(input, { target: { value: 'ab' } });
fireEvent.change(input, { target: { value: 'abc' } });
expect((input as HTMLInputElement).value).toBe('abc');
expect(document.activeElement).toBe(input);
expect(container.querySelector('.menu-editor-row .menu-editor-inline-input')).toBeNull();
});
it('renders all matching page results without UI capping', async () => {
const pagePosts = Array.from({ length: 12 }).map((_, index) => ({
id: `page-${index + 1}`,
projectId: 'project-1',
title: `Page ${index + 1}`,
slug: `page-${index + 1}`,
content: '',
status: 'published',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: [],
categories: ['page'],
}));
(window as any).electronAPI.posts.filter = vi.fn().mockResolvedValue(pagePosts);
render(<MenuEditorView />);
const addButton = await screen.findByRole('button', { name: /add entry/i });
fireEvent.click(addButton);
const input = await screen.findByPlaceholderText(/type a page title or submenu label/i);
fireEvent.change(input, { target: { value: 'page' } });
const options = await screen.findAllByRole('button', { name: /page\s+\d+/i });
expect(options).toHaveLength(12);
});
it('shows standard outliner control buttons in the toolbar', async () => {
render(<MenuEditorView />);
await screen.findByRole('button', { name: /add entry/i });
expect(screen.getByRole('button', { name: /^move up$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^move down$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^indent$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^unindent$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument();
});
it('finalizes entry as page on a double-click gesture', async () => {
render(<MenuEditorView />);
const addButton = await screen.findByRole('button', { name: /add entry/i });
fireEvent.click(addButton);
const pageOption = await screen.findByRole('button', { name: /about/i });
fireEvent.doubleClick(pageOption);
expect(screen.queryByPlaceholderText(/type a page title or submenu label/i)).not.toBeInTheDocument();
expect(screen.getByText('About')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import type { MenuItemData } from '../../../src/main/shared/electronApi';
import { resolveInsertTarget } from '../../../src/renderer/components/MenuEditorView/menuInsertTarget';
function createTree(): MenuItemData[] {
return [
{
id: 'home',
title: 'Home',
kind: 'page',
children: [],
},
{
id: 'docs',
title: 'Docs',
kind: 'submenu',
children: [
{
id: 'about',
title: 'About',
kind: 'page',
children: [],
},
],
},
];
}
describe('resolveInsertTarget', () => {
it('inserts on root level when no selection exists', () => {
const result = resolveInsertTarget(createTree(), null);
expect(result).toEqual({ parentPath: [], index: 2 });
});
it('inserts as first child when selected node is submenu', () => {
const result = resolveInsertTarget(createTree(), 'docs');
expect(result).toEqual({ parentPath: [1], index: 0 });
});
it('inserts as next sibling when selected node is page', () => {
const result = resolveInsertTarget(createTree(), 'home');
expect(result).toEqual({ parentPath: [], index: 1 });
});
});