fix: category titles in the menu outline

This commit is contained in:
2026-02-22 07:47:36 +01:00
parent 0d86fe1c9d
commit c58df4b107
2 changed files with 86 additions and 21 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, 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';
@@ -192,6 +192,7 @@ export const MenuEditorView: React.FC = () => {
const [isLoadingPages, setIsLoadingPages] = useState(false); const [isLoadingPages, setIsLoadingPages] = useState(false);
const [pagePosts, setPagePosts] = useState<PostData[]>([]); const [pagePosts, setPagePosts] = useState<PostData[]>([]);
const [categories, setCategories] = useState<CategoryOption[]>([]); const [categories, setCategories] = useState<CategoryOption[]>([]);
const [categoryTitlesByName, setCategoryTitlesByName] = useState<Record<string, string>>({});
const [isLoadingCategories, setIsLoadingCategories] = useState(false); const [isLoadingCategories, setIsLoadingCategories] = useState(false);
const [editingEntryId, setEditingEntryId] = useState<string | null>(null); const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null); const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null);
@@ -202,12 +203,33 @@ export const MenuEditorView: React.FC = () => {
const toolbarRef = useRef<HTMLDivElement | null>(null); const toolbarRef = useRef<HTMLDivElement | null>(null);
const recentInsertTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const recentInsertTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoExpandController = useMemo(() => createAutoExpandController(450), []); const autoExpandController = useMemo(() => createAutoExpandController(450), []);
const recalculateTreeHeight = useCallback((): void => {
const wrap = treeWrapRef.current;
const toolbar = toolbarRef.current;
if (!wrap) {
return;
}
const wrapHeight = wrap.clientHeight;
const toolbarHeight = toolbar?.offsetHeight ?? 0;
const next = Math.max(120, wrapHeight - toolbarHeight - 8);
setTreeHeight(next);
}, []);
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const menu = await window.electronAPI.menu.get(); const [menu, projectMetadata] = await Promise.all([
window.electronAPI.menu.get(),
window.electronAPI.meta.getProjectMetadata().catch(() => null),
]);
const metadataEntries = Object.entries(projectMetadata?.categoryMetadata ?? {});
setCategoryTitlesByName(Object.fromEntries(
metadataEntries.map(([name, metadata]) => [name, metadata.title?.trim() || name]),
));
setItems(menu.items); setItems(menu.items);
setSelectedId(menu.items[0]?.id ?? null); setSelectedId(menu.items[0]?.id ?? null);
} catch (error) { } catch (error) {
@@ -280,34 +302,21 @@ export const MenuEditorView: React.FC = () => {
}, [editingEntryId, editingEntryType, isLoadingPages, isLoadingCategories]); }, [editingEntryId, editingEntryType, isLoadingPages, isLoadingCategories]);
useEffect(() => { useEffect(() => {
const updateTreeHeight = (): void => { recalculateTreeHeight();
const wrap = treeWrapRef.current;
const toolbar = toolbarRef.current;
if (!wrap) {
return;
}
const wrapHeight = wrap.clientHeight;
const toolbarHeight = toolbar?.offsetHeight ?? 0;
const next = Math.max(120, wrapHeight - toolbarHeight - 8);
setTreeHeight(next);
};
updateTreeHeight();
if (typeof ResizeObserver === 'undefined') { if (typeof ResizeObserver === 'undefined') {
if (typeof window.addEventListener !== 'function') { if (typeof window.addEventListener !== 'function') {
return; return;
} }
window.addEventListener('resize', updateTreeHeight); window.addEventListener('resize', recalculateTreeHeight);
return () => { return () => {
window.removeEventListener('resize', updateTreeHeight); window.removeEventListener('resize', recalculateTreeHeight);
}; };
} }
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
updateTreeHeight(); recalculateTreeHeight();
}); });
if (treeWrapRef.current) { if (treeWrapRef.current) {
@@ -320,7 +329,21 @@ export const MenuEditorView: React.FC = () => {
return () => { return () => {
observer.disconnect(); observer.disconnect();
}; };
}, [editingEntryId]); }, [editingEntryId, isLoading, recalculateTreeHeight]);
useEffect(() => {
if (isLoading) {
return;
}
const timer = setTimeout(() => {
recalculateTreeHeight();
}, 0);
return () => {
clearTimeout(timer);
};
}, [isLoading, recalculateTreeHeight]);
const selectedPath = useMemo(() => { const selectedPath = useMemo(() => {
if (!selectedId) { if (!selectedId) {
@@ -360,6 +383,9 @@ export const MenuEditorView: React.FC = () => {
]); ]);
const categoryMetadata = projectMetadata?.categoryMetadata ?? {}; const categoryMetadata = projectMetadata?.categoryMetadata ?? {};
setCategoryTitlesByName(Object.fromEntries(
Object.entries(categoryMetadata).map(([name, metadata]) => [name, metadata.title?.trim() || name]),
));
setCategories(nextCategories.map((categoryName) => ({ setCategories(nextCategories.map((categoryName) => ({
name: categoryName, name: categoryName,
title: categoryMetadata[categoryName]?.title?.trim() || categoryName, title: categoryMetadata[categoryName]?.title?.trim() || categoryName,
@@ -818,7 +844,9 @@ export const MenuEditorView: React.FC = () => {
inlinePlain inlinePlain
/> />
) )
) : node.data.title} ) : node.data.kind === 'category-archive' && node.data.categoryName
? (categoryTitlesByName[node.data.categoryName] || node.data.title)
: node.data.title}
</span> </span>
</> </>
</div> </div>

View File

@@ -216,6 +216,33 @@ describe('MenuEditorView entry editor', () => {
expect(await screen.findByPlaceholderText(/type a category name/i)).toBeInTheDocument(); expect(await screen.findByPlaceholderText(/type a category name/i)).toBeInTheDocument();
}); });
it('shows category metadata title in outline rows for category archives', async () => {
(window as any).electronAPI.menu.get = vi.fn().mockResolvedValue({
items: [
{
id: 'menu-home',
title: 'Home',
kind: 'home',
pageSlug: 'home',
children: [],
},
{
id: 'cat-news',
title: 'news',
kind: 'category-archive',
categoryName: 'news',
children: [],
},
],
});
render(<MenuEditorView />);
await screen.findByText('Home');
expect(screen.getByText('Newsroom')).toBeInTheDocument();
expect(screen.queryByText(/^news$/i)).not.toBeInTheDocument();
});
it('disables delete action when Home entry is selected', async () => { it('disables delete action when Home entry is selected', async () => {
render(<MenuEditorView />); render(<MenuEditorView />);
@@ -235,6 +262,16 @@ describe('MenuEditorView entry editor', () => {
expect(screen.queryByText(/^submenu$/i)).not.toBeInTheDocument(); expect(screen.queryByText(/^submenu$/i)).not.toBeInTheDocument();
}); });
it('recalculates tree viewport height after loading so outline uses available space', async () => {
const { container } = render(<MenuEditorView />);
await screen.findByText('Home');
await testUtils.wait(0);
const tree = container.querySelector('[role="tree"]') as HTMLElement | null;
expect(tree).not.toBeNull();
expect(tree?.style.height).not.toBe('460px');
});
it('uses category titles for suggestions and outline while saving category slug internally', async () => { it('uses category titles for suggestions and outline while saving category slug internally', async () => {
render(<MenuEditorView />); render(<MenuEditorView />);