feat: category menus
This commit is contained in:
18
src/renderer/components/CategoryInput/CategoryInput.css
Normal file
18
src/renderer/components/CategoryInput/CategoryInput.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.category-input-wrapper-inline {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.category-input-wrapper-inline:focus-within {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.category-input-field-inline {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
172
src/renderer/components/CategoryInput/CategoryInput.tsx
Normal file
172
src/renderer/components/CategoryInput/CategoryInput.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import '../TagInput/TagInput.css';
|
||||
import './CategoryInput.css';
|
||||
|
||||
interface CategoryInputProps {
|
||||
categories: string[];
|
||||
onSelectCategory: (categoryName: string) => void;
|
||||
placeholder?: string;
|
||||
createCategoryArchiveLabel: string;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
inlinePlain?: boolean;
|
||||
}
|
||||
|
||||
export const CategoryInput: React.FC<CategoryInputProps> = ({
|
||||
categories,
|
||||
onSelectCategory,
|
||||
placeholder = '',
|
||||
createCategoryArchiveLabel,
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
inlinePlain = false,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
if (!inputValue.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const query = inputValue.toLowerCase().trim();
|
||||
return categories
|
||||
.filter((categoryName) => categoryName.toLowerCase().includes(query))
|
||||
.slice(0, 8);
|
||||
}, [categories, inputValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [autoFocus, disabled]);
|
||||
|
||||
const createArchive = (label: string): void => {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectCategory(trimmed);
|
||||
setInputValue('');
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
const exactMatchExists = inputValue.trim()
|
||||
? suggestions.some((item) => item.toLowerCase() === inputValue.trim().toLowerCase())
|
||||
: false;
|
||||
|
||||
const showCreateOption = inputValue.trim() && !exactMatchExists;
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
const maxIndex = suggestions.length + (showCreateOption ? 0 : -1);
|
||||
setSelectedIndex((previous) => Math.min(previous + 1, maxIndex));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((previous) => Math.max(previous - 1, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
createArchive(suggestions[selectedIndex]);
|
||||
} else if (selectedIndex === suggestions.length && showCreateOption) {
|
||||
createArchive(inputValue);
|
||||
} else {
|
||||
const exactMatch = categories.find((categoryName) => categoryName.toLowerCase() === inputValue.trim().toLowerCase());
|
||||
if (exactMatch) {
|
||||
createArchive(exactMatch);
|
||||
} else if (inputValue.trim()) {
|
||||
createArchive(inputValue);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setShowSuggestions(false);
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tag-input-container" ref={containerRef}>
|
||||
<div className={`tag-input-wrapper ${inlinePlain ? 'category-input-wrapper-inline' : ''}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={`tag-input-field ${inlinePlain ? 'category-input-field-inline' : ''}`}
|
||||
value={inputValue}
|
||||
autoFocus={autoFocus}
|
||||
onChange={(event) => {
|
||||
setInputValue(event.target.value);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onInput={(event) => {
|
||||
setInputValue((event.target as HTMLInputElement).value);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showSuggestions && (suggestions.length > 0 || showCreateOption) && (
|
||||
<div className="tag-suggestions">
|
||||
{suggestions.map((categoryName, index) => (
|
||||
<button
|
||||
key={categoryName}
|
||||
type="button"
|
||||
className={`tag-suggestion ${selectedIndex === index ? 'selected' : ''}`}
|
||||
onClick={() => createArchive(categoryName)}
|
||||
>
|
||||
<span className="tag-suggestion-name">{categoryName}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{showCreateOption && (
|
||||
<button
|
||||
type="button"
|
||||
className={`tag-suggestion create-new ${selectedIndex === suggestions.length ? 'selected' : ''}`}
|
||||
onClick={() => createArchive(inputValue)}
|
||||
>
|
||||
<span className="tag-suggestion-icon">+</span>
|
||||
<span>{createCategoryArchiveLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/CategoryInput/index.ts
Normal file
1
src/renderer/components/CategoryInput/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CategoryInput } from './CategoryInput';
|
||||
@@ -29,12 +29,16 @@
|
||||
}
|
||||
|
||||
.menu-editor-main {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-editor-tree-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-editor-background);
|
||||
@@ -42,6 +46,11 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.menu-editor-tree-wrap [role='tree'] {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.menu-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,12 +4,15 @@ import { useI18n } from '../../i18n';
|
||||
import { showToast } from '../Toast';
|
||||
import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi';
|
||||
import { PageInput } from '../PageInput';
|
||||
import { CategoryInput } from '../CategoryInput';
|
||||
import { createAutoExpandController } from './menuAutoExpand';
|
||||
import { resolveInsertTarget } from './menuInsertTarget';
|
||||
import { isPickerCloseKey } from './menuPagePicker';
|
||||
import { applyTreeMove } from './menuTreeMove';
|
||||
import './MenuEditorView.css';
|
||||
|
||||
const HOME_MENU_ID = 'menu-home';
|
||||
|
||||
interface ToolButtonProps {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
@@ -152,13 +155,14 @@ function mapItems(items: MenuItemData[], mapper: (item: MenuItemData) => MenuIte
|
||||
});
|
||||
}
|
||||
|
||||
function createDraftEntry(): MenuItemData {
|
||||
function createDraftEntry(kind: MenuItemData['kind'] = 'submenu'): MenuItemData {
|
||||
return {
|
||||
id: createMenuItemId(),
|
||||
title: '',
|
||||
kind: 'submenu',
|
||||
kind,
|
||||
pageId: undefined,
|
||||
pageSlug: undefined,
|
||||
categoryName: undefined,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
@@ -171,9 +175,15 @@ export const MenuEditorView: React.FC = () => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoadingPages, setIsLoadingPages] = useState(false);
|
||||
const [pagePosts, setPagePosts] = useState<PostData[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
||||
const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null);
|
||||
const [treeHeight, setTreeHeight] = useState<number>(460);
|
||||
const [toolbarTooltip, setToolbarTooltip] = useState<string>('');
|
||||
const [recentParentInsertId, setRecentParentInsertId] = useState<string | null>(null);
|
||||
const treeWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||
const recentInsertTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoExpandController = useMemo(() => createAutoExpandController(450), []);
|
||||
|
||||
@@ -220,6 +230,7 @@ export const MenuEditorView: React.FC = () => {
|
||||
return removeItemByPath(previous, path).next;
|
||||
});
|
||||
setEditingEntryId(null);
|
||||
setEditingEntryType(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -230,7 +241,7 @@ export const MenuEditorView: React.FC = () => {
|
||||
}, [editingEntryId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingEntryId || isLoadingPages) {
|
||||
if (!editingEntryId || (editingEntryType === 'page' && isLoadingPages) || (editingEntryType === 'category' && isLoadingCategories)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,7 +261,50 @@ export const MenuEditorView: React.FC = () => {
|
||||
clearTimeout(immediate);
|
||||
clearTimeout(delayed);
|
||||
};
|
||||
}, [editingEntryId, isLoadingPages]);
|
||||
}, [editingEntryId, editingEntryType, isLoadingPages, isLoadingCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateTreeHeight = (): 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);
|
||||
};
|
||||
|
||||
updateTreeHeight();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
if (typeof window.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateTreeHeight);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateTreeHeight);
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateTreeHeight();
|
||||
});
|
||||
|
||||
if (treeWrapRef.current) {
|
||||
observer.observe(treeWrapRef.current);
|
||||
}
|
||||
if (toolbarRef.current) {
|
||||
observer.observe(toolbarRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [editingEntryId]);
|
||||
|
||||
const selectedPath = useMemo(() => {
|
||||
if (!selectedId) {
|
||||
@@ -277,6 +331,24 @@ export const MenuEditorView: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCategoriesLoaded = async (): Promise<void> => {
|
||||
if (categories.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingCategories(true);
|
||||
try {
|
||||
const nextCategories = await window.electronAPI.meta.getCategories();
|
||||
setCategories(nextCategories);
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories:', error);
|
||||
showToast.error(tr('menuEditor.categoryPicker.loadError'));
|
||||
setCategories([]);
|
||||
} finally {
|
||||
setIsLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setDraftAsSubmenu = (label: string): void => {
|
||||
if (!editingEntryId) {
|
||||
return;
|
||||
@@ -300,6 +372,7 @@ export const MenuEditorView: React.FC = () => {
|
||||
}));
|
||||
|
||||
setEditingEntryId(null);
|
||||
setEditingEntryType(null);
|
||||
};
|
||||
|
||||
const setDraftAsPage = (post: PostData): void => {
|
||||
@@ -322,6 +395,36 @@ export const MenuEditorView: React.FC = () => {
|
||||
}));
|
||||
|
||||
setEditingEntryId(null);
|
||||
setEditingEntryType(null);
|
||||
};
|
||||
|
||||
const setDraftAsCategoryArchive = (categoryName: string): void => {
|
||||
if (!editingEntryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = categoryName.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((previous) => mapItems(previous, (item) => {
|
||||
if (item.id !== editingEntryId) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
title: trimmed,
|
||||
kind: 'category-archive',
|
||||
pageId: undefined,
|
||||
pageSlug: undefined,
|
||||
categoryName: trimmed,
|
||||
};
|
||||
}));
|
||||
|
||||
setEditingEntryId(null);
|
||||
setEditingEntryType(null);
|
||||
};
|
||||
|
||||
const startCreateEntry = async (): Promise<void> => {
|
||||
@@ -360,6 +463,22 @@ export const MenuEditorView: React.FC = () => {
|
||||
|
||||
setSelectedId(newEntry.id);
|
||||
setEditingEntryId(newEntry.id);
|
||||
setEditingEntryType('page');
|
||||
};
|
||||
|
||||
const startCreateCategoryArchive = async (): Promise<void> => {
|
||||
await ensureCategoriesLoaded();
|
||||
|
||||
const newEntry = createDraftEntry('category-archive');
|
||||
const target = resolveInsertTarget(items, selectedId);
|
||||
|
||||
setItems((previous) => {
|
||||
return insertItemAtPath(previous, target.parentPath, target.index, newEntry);
|
||||
});
|
||||
|
||||
setSelectedId(newEntry.id);
|
||||
setEditingEntryId(newEntry.id);
|
||||
setEditingEntryType('category');
|
||||
};
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
@@ -445,6 +564,10 @@ export const MenuEditorView: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedId === HOME_MENU_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((previous) => {
|
||||
const removed = removeItemByPath(previous, selectedPath);
|
||||
return removed.next;
|
||||
@@ -452,10 +575,13 @@ export const MenuEditorView: React.FC = () => {
|
||||
|
||||
if (editingEntryId === selectedId) {
|
||||
setEditingEntryId(null);
|
||||
setEditingEntryType(null);
|
||||
}
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const isHomeSelected = selectedId === HOME_MENU_ID;
|
||||
|
||||
return (
|
||||
<div className="menu-editor-view">
|
||||
<div className="menu-editor-header">
|
||||
@@ -469,8 +595,8 @@ export const MenuEditorView: React.FC = () => {
|
||||
<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')}>
|
||||
<div className="menu-editor-tree-wrap" ref={treeWrapRef}>
|
||||
<div className="menu-editor-toolbar" role="toolbar" aria-label={tr('menuEditor.title')} ref={toolbarRef}>
|
||||
<ToolButton
|
||||
label={tr('menuEditor.addEntry')}
|
||||
onClick={() => void startCreateEntry()}
|
||||
@@ -488,6 +614,14 @@ export const MenuEditorView: React.FC = () => {
|
||||
>
|
||||
<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.addCategoryArchive')}
|
||||
onClick={() => void startCreateCategoryArchive()}
|
||||
onShowTooltip={setToolbarTooltip}
|
||||
onHideTooltip={() => setToolbarTooltip('')}
|
||||
>
|
||||
<span aria-hidden="true">{tr('menuEditor.addCategoryArchiveShort')}</span>
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label={tr('menuEditor.moveUp')}
|
||||
onClick={() => moveSelected('up')}
|
||||
@@ -527,7 +661,7 @@ export const MenuEditorView: React.FC = () => {
|
||||
<ToolButton
|
||||
label={tr('menuEditor.delete')}
|
||||
onClick={deleteSelected}
|
||||
disabled={!selectedPath}
|
||||
disabled={!selectedPath || isHomeSelected}
|
||||
onShowTooltip={setToolbarTooltip}
|
||||
onHideTooltip={() => setToolbarTooltip('')}
|
||||
>
|
||||
@@ -544,7 +678,7 @@ export const MenuEditorView: React.FC = () => {
|
||||
<Tree<MenuItemData>
|
||||
data={items}
|
||||
width="100%"
|
||||
height={editingEntryId ? 320 : 460}
|
||||
height={treeHeight}
|
||||
rowHeight={32}
|
||||
indent={20}
|
||||
openByDefault
|
||||
@@ -584,20 +718,36 @@ export const MenuEditorView: React.FC = () => {
|
||||
>
|
||||
<>
|
||||
<span className="menu-editor-row-kind">
|
||||
{node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')}
|
||||
{node.data.kind === 'page'
|
||||
? tr('menuEditor.type.page')
|
||||
: node.data.kind === 'category-archive'
|
||||
? tr('menuEditor.type.categoryArchive')
|
||||
: 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
|
||||
/>
|
||||
editingEntryType === 'category' ? (
|
||||
<CategoryInput
|
||||
categories={categories}
|
||||
onSelectCategory={setDraftAsCategoryArchive}
|
||||
createCategoryArchiveLabel={tr('menuEditor.addCategoryArchive')}
|
||||
placeholder={tr('menuEditor.newCategoryPlaceholder')}
|
||||
disabled={isLoadingCategories}
|
||||
autoFocus
|
||||
inlinePlain
|
||||
/>
|
||||
) : (
|
||||
<PageInput
|
||||
pages={pagePosts}
|
||||
onSelectPage={setDraftAsPage}
|
||||
onCreateSubmenu={setDraftAsSubmenu}
|
||||
createSubmenuLabel={tr('menuEditor.addSubmenu')}
|
||||
placeholder={tr('menuEditor.newEntryPlaceholder')}
|
||||
disabled={isLoadingPages}
|
||||
autoFocus
|
||||
inlinePlain
|
||||
/>
|
||||
)
|
||||
) : node.data.title}
|
||||
</span>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user