Skip to content

Commit 9b0c280

Browse files
committed
カテゴリ機能を実装
1 parent 6281654 commit 9b0c280

11 files changed

Lines changed: 354 additions & 27 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { memo } from 'react';
2+
import { CommandDefinition } from '../../types';
3+
import { CategoryNode } from '../../utils/commandCategories';
4+
import { useCategoryTranslation } from '../../hooks/useCategoryTranslation';
5+
6+
interface CategoryMenuRendererProps {
7+
categoryNode: CategoryNode;
8+
onSelectCommand: (commandType: string) => void;
9+
// UI components to use for rendering
10+
components: {
11+
MenuItem: React.ComponentType<any>;
12+
MenuSub: React.ComponentType<any>;
13+
MenuSubTrigger: React.ComponentType<any>;
14+
MenuSubContent: React.ComponentType<any>;
15+
MenuSeparator: React.ComponentType<any>;
16+
};
17+
// Optional custom renderer for command items
18+
renderCommand?: (command: CommandDefinition, onClick: () => void) => React.ReactNode;
19+
}
20+
21+
/**
22+
* Generic category menu renderer that can be used with different menu UI components
23+
* (DropdownMenu, ContextMenu, etc.)
24+
*/
25+
export const CategoryMenuRenderer = memo(({
26+
categoryNode,
27+
onSelectCommand,
28+
components,
29+
renderCommand
30+
}: CategoryMenuRendererProps) => {
31+
const { tCategory } = useCategoryTranslation();
32+
const { MenuItem, MenuSub, MenuSubTrigger, MenuSubContent, MenuSeparator } = components;
33+
34+
return (
35+
<>
36+
{/* Render commands at this level */}
37+
{categoryNode.commands.map((command) => (
38+
<MenuItem
39+
key={command.id}
40+
onClick={() => onSelectCommand(command.id)}
41+
>
42+
{renderCommand ? renderCommand(command, () => onSelectCommand(command.id)) : command.label}
43+
</MenuItem>
44+
))}
45+
46+
{/* Add separator if there are both commands and subcategories */}
47+
{categoryNode.commands.length > 0 && categoryNode.children.length > 0 && (
48+
<MenuSeparator />
49+
)}
50+
51+
{/* Render subcategories */}
52+
{categoryNode.children.map((childCategory) => (
53+
<MenuSub key={childCategory.name}>
54+
<MenuSubTrigger>
55+
{tCategory(childCategory.name)}
56+
</MenuSubTrigger>
57+
<MenuSubContent>
58+
<CategoryMenuRenderer
59+
categoryNode={childCategory}
60+
onSelectCommand={onSelectCommand}
61+
components={components}
62+
renderCommand={renderCommand}
63+
/>
64+
</MenuSubContent>
65+
</MenuSub>
66+
))}
67+
</>
68+
);
69+
});
70+
71+
CategoryMenuRenderer.displayName = 'CategoryMenuRenderer';

frontend/src/components/skit/CommandList.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,33 @@ vi.mock('../../hooks/useCommandTranslation', () => ({
6666
}),
6767
}));
6868

69+
// Mock context menu components
70+
vi.mock('../ui/context-menu', () => ({
71+
ContextMenu: ({ children }: any) => <>{children}</>,
72+
ContextMenuContent: ({ children }: any) => <div>{children}</div>,
73+
ContextMenuItem: ({ children, onClick }: any) => (
74+
<div onClick={onClick}>{children}</div>
75+
),
76+
ContextMenuSeparator: () => <hr />,
77+
ContextMenuSub: ({ children }: any) => <>{children}</>,
78+
ContextMenuSubContent: ({ children }: any) => <div>{children}</div>,
79+
ContextMenuSubTrigger: ({ children }: any) => <div>{children}</div>,
80+
ContextMenuTrigger: ({ children }: any) => <>{children}</>
81+
}));
82+
83+
// Mock CategoryMenuRenderer
84+
vi.mock('../common/CategoryMenuRenderer', () => ({
85+
CategoryMenuRenderer: ({ categoryNode, onSelectCommand }: any) => (
86+
<div data-testid="category-menu-renderer">
87+
{categoryNode.commands?.map((cmd: any) => (
88+
<div key={cmd.id} onClick={() => onSelectCommand(cmd.id)}>
89+
{cmd.label}
90+
</div>
91+
))}
92+
</div>
93+
)
94+
}));
95+
6996
vi.mock('react-i18next', () => ({
7097
useTranslation: () => ({
7198
t: (key: string) => {

frontend/src/components/skit/CommandList.tsx

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { useMemo, useCallback, memo } from 'react';
2121
import { ChevronDown, ChevronRight } from 'lucide-react';
2222
import { useCommandTranslation } from '../../hooks/useCommandTranslation';
2323
import { useTranslation } from 'react-i18next';
24+
import { groupCommandsByCategory } from '../../utils/commandCategories';
25+
import { CategoryMenuRenderer } from '../common/CategoryMenuRenderer';
2426

2527
/**
2628
* CommandList コンポーネント - パフォーマンス最適化済み
@@ -447,6 +449,7 @@ const CommandItem = memo(({
447449
*/
448450

449451
// コンテキストメニューをメモ化コンポーネントとして分離
452+
450453
const CommandContextMenu = memo(({
451454
command,
452455
index,
@@ -470,6 +473,9 @@ const CommandContextMenu = memo(({
470473
}) => {
471474
const isGroupStart = command.type === 'group_start';
472475

476+
// Group commands by category
477+
const commandCategories = groupCommandsByCategory(commandDefinitions);
478+
473479
return (
474480
<ContextMenuContent>
475481
<ContextMenuItem
@@ -501,28 +507,34 @@ const CommandContextMenu = memo(({
501507
<ContextMenuSub>
502508
<ContextMenuSubTrigger>上にコマンドを追加</ContextMenuSubTrigger>
503509
<ContextMenuSubContent>
504-
{commandDefinitions.map((cmdDef: CommandDefinition) => (
505-
<ContextMenuItem
506-
key={cmdDef.id}
507-
onClick={() => handleAddCommand(cmdDef.id, index, 'above')}
508-
>
509-
{cmdDef.label}
510-
</ContextMenuItem>
511-
))}
510+
<CategoryMenuRenderer
511+
categoryNode={commandCategories}
512+
onSelectCommand={(commandType) => handleAddCommand(commandType, index, 'above')}
513+
components={{
514+
MenuItem: ContextMenuItem,
515+
MenuSub: ContextMenuSub,
516+
MenuSubTrigger: ContextMenuSubTrigger,
517+
MenuSubContent: ContextMenuSubContent,
518+
MenuSeparator: ContextMenuSeparator
519+
}}
520+
/>
512521
</ContextMenuSubContent>
513522
</ContextMenuSub>
514523

515524
<ContextMenuSub>
516525
<ContextMenuSubTrigger>下にコマンドを追加</ContextMenuSubTrigger>
517526
<ContextMenuSubContent>
518-
{commandDefinitions.map((cmdDef: CommandDefinition) => (
519-
<ContextMenuItem
520-
key={cmdDef.id}
521-
onClick={() => handleAddCommand(cmdDef.id, index, 'below')}
522-
>
523-
{cmdDef.label}
524-
</ContextMenuItem>
525-
))}
527+
<CategoryMenuRenderer
528+
categoryNode={commandCategories}
529+
onSelectCommand={(commandType) => handleAddCommand(commandType, index, 'below')}
530+
components={{
531+
MenuItem: ContextMenuItem,
532+
MenuSub: ContextMenuSub,
533+
MenuSubTrigger: ContextMenuSubTrigger,
534+
MenuSubContent: ContextMenuSubContent,
535+
MenuSeparator: ContextMenuSeparator
536+
}}
537+
/>
526538
</ContextMenuSubContent>
527539
</ContextMenuSub>
528540
</ContextMenuContent>

frontend/src/components/skit/Toolbar.test.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,27 @@ vi.mock('../ui/dropdown-menu', () => {
5050
<div role="menuitem" onClick={onClick}>
5151
{children}
5252
</div>
53-
)
53+
),
54+
DropdownMenuSub: ({ children }: any) => <>{children}</>,
55+
DropdownMenuSubTrigger: ({ children }: any) => <div>{children}</div>,
56+
DropdownMenuSubContent: ({ children }: any) => <div>{children}</div>,
57+
DropdownMenuSeparator: () => <hr />
5458
};
5559
});
5660

61+
// Mock CategoryMenuRenderer
62+
vi.mock('../common/CategoryMenuRenderer', () => ({
63+
CategoryMenuRenderer: ({ categoryNode, onSelectCommand }: any) => (
64+
<div data-testid="category-menu-renderer">
65+
{categoryNode.commands?.map((cmd: any) => (
66+
<div key={cmd.id} onClick={() => onSelectCommand(cmd.id)}>
67+
{cmd.label}
68+
</div>
69+
))}
70+
</div>
71+
)
72+
}));
73+
5774
describe('Toolbar', () => {
5875
const mockAddCommand = vi.fn();
5976
const mockMoveCommand = vi.fn();

frontend/src/components/skit/Toolbar.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import {
1717
DropdownMenu,
1818
DropdownMenuContent,
1919
DropdownMenuItem,
20-
DropdownMenuTrigger
20+
DropdownMenuTrigger,
21+
DropdownMenuSub,
22+
DropdownMenuSubContent,
23+
DropdownMenuSubTrigger,
24+
DropdownMenuSeparator
2125
} from '../ui/dropdown-menu';
2226
import { CommandDefinition } from '../../types';
2327
import { toast } from 'sonner';
@@ -27,13 +31,16 @@ import { DropZone } from '../dnd/DropZone';
2731
import { useTranslation } from 'react-i18next';
2832
import { useCommandTranslation } from '../../hooks/useCommandTranslation';
2933
import { openProjectDirectory } from '../../utils/fileSystem';
34+
import { groupCommandsByCategory } from '../../utils/commandCategories';
35+
import { CategoryMenuRenderer } from '../common/CategoryMenuRenderer';
3036

3137
// Helper component to display translated command label
3238
function CommandMenuLabel({ commandId, label }: { commandId: string; label?: string }) {
3339
const { tCommand } = useCommandTranslation(commandId);
3440
return <>{tCommand('name', label || commandId)}</>;
3541
}
3642

43+
3744
export function Toolbar() {
3845
const { t } = useTranslation();
3946
const {
@@ -98,6 +105,9 @@ export function Toolbar() {
98105

99106
const isDisabled = !currentSkitId;
100107
const isCommandSelected = selectedCommandIds.length > 0;
108+
109+
// Group commands by category
110+
const commandCategories = groupCommandsByCategory(commandDefinitions);
101111

102112
return (
103113
<div className="flex items-center p-2 border-b w-full">
@@ -118,18 +128,24 @@ export function Toolbar() {
118128
</Button>
119129
</DropdownMenuTrigger>
120130
<DropdownMenuContent align="start">
121-
{commandDefinitions.map((command: CommandDefinition) => (
122-
<DropdownMenuItem
123-
key={command.id}
124-
onClick={() => handleAddCommand(command.id)}
125-
>
131+
<CategoryMenuRenderer
132+
categoryNode={commandCategories}
133+
onSelectCommand={handleAddCommand}
134+
components={{
135+
MenuItem: DropdownMenuItem,
136+
MenuSub: DropdownMenuSub,
137+
MenuSubTrigger: DropdownMenuSubTrigger,
138+
MenuSubContent: DropdownMenuSubContent,
139+
MenuSeparator: DropdownMenuSeparator
140+
}}
141+
renderCommand={(command) => (
126142
<DraggableCommand id={command.id}>
127143
<div className="flex items-center w-full">
128144
<CommandMenuLabel commandId={command.id} label={command.label} />
129145
</div>
130146
</DraggableCommand>
131-
</DropdownMenuItem>
132-
))}
147+
)}
148+
/>
133149
</DropdownMenuContent>
134150
</DropdownMenu>
135151

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useTranslation } from 'react-i18next';
2+
3+
/**
4+
* カテゴリー名の翻訳を取得するフック
5+
* プロジェクトのi18nフォルダから動的に読み込まれた翻訳を使用
6+
*/
7+
export function useCategoryTranslation() {
8+
const { t } = useTranslation();
9+
10+
/**
11+
* カテゴリー名を翻訳
12+
* @param categoryName - カテゴリー名(例: "Character", "Emotion")
13+
* @param fallback - 翻訳が見つからない場合のフォールバック値
14+
* @returns 翻訳されたカテゴリー名
15+
*/
16+
const tCategory = (categoryName: string, fallback?: string): string => {
17+
const translationKey = `category.${categoryName}`;
18+
const translated = t(translationKey);
19+
20+
// 翻訳キーがそのまま返ってきた場合はフォールバックを使用
21+
if (translated === translationKey) {
22+
return fallback || categoryName;
23+
}
24+
25+
return translated;
26+
};
27+
28+
return { tCategory };
29+
}

0 commit comments

Comments
 (0)