diff --git a/frontend/components/layout/entity-sidebar.tsx b/frontend/components/layout/entity-sidebar.tsx index af0b827..704e0e0 100644 --- a/frontend/components/layout/entity-sidebar.tsx +++ b/frontend/components/layout/entity-sidebar.tsx @@ -12,6 +12,7 @@ import { Sidebar, SidebarTrigger, SidebarProvider, + SidebarInput, } from "@/components/ui/sidebar"; import { CheckSquare, @@ -27,10 +28,11 @@ import { } from "../ui/collapsible"; import { SearchForm } from "../form/search-form"; import { useEntityStore } from "@/store/entityStore"; -import React, { createContext, useContext, useState } from "react"; +import React, { createContext, useContext, useState, useCallback } from "react"; import { useUsemapStore } from "@/components/pages/editor-page"; -import { Asset } from "@/types/schemas/asset/Asset"; +import { AssetNode } from "@/types/asset"; import { useFilteredAssetTree } from "@/hooks/useAssetTree"; +import { Entity } from "@/types/schemas/entities/Entity"; interface EntitySidebarContextProps { searchTerm: string; @@ -51,6 +53,14 @@ function useEntitySidebar() { return context; } +const getAllEntities = (entityAsset: AssetNode) => { + const entities: AssetNode[] = [entityAsset]; + entityAsset.children.forEach((child) => { + entities.push(...getAllEntities(child)); + }); + return entities; +}; + export function EntitySidebarItemLabel({ handleCheck, asset, @@ -58,12 +68,78 @@ export function EntitySidebarItemLabel({ ...props }: React.ComponentProps & { handleCheck: (e: React.MouseEvent) => void; - asset: Asset; + asset: AssetNode; }) { const checkedEntities = useEntityStore((state) => state.checkedEntities); + const { deleteEntity, updateEntityAssetName } = useUsemapStore( + (state) => state, + ); + const [isRenaming, setIsRenaming] = useState(false); + const [inputValue, setInputValue] = useState(asset.name); + + const handleSave = useCallback(() => { + try { + updateEntityAssetName(asset.id, inputValue); + } catch (err) { + console.error("Failed to rename asset:", err); + } + }, [asset.id, asset.name, inputValue]); + + const handleCancel = useCallback(() => { + console.log("canceledd"); + setInputValue(asset.name); + setIsRenaming(false); + }, [asset.name]); + + const handleKeydown = useCallback( + (e: React.KeyboardEvent) => { + // rename 모드일 때는 SidebarInput이 키 이벤트를 처리하도록 함 + if (isRenaming) return; + + switch (e.key) { + case "r": + case "R": + case "F2": { + e.preventDefault(); + e.stopPropagation(); + setIsRenaming(true); + setInputValue(asset.name); + break; + } + case "Delete": + e.preventDefault(); + e.stopPropagation(); + getAllEntities(asset).forEach((e) => deleteEntity(e)); + break; + } + }, + [isRenaming, asset.name], + ); + + const handleInputKeydown = useCallback( + (e: React.KeyboardEvent) => { + e.stopPropagation(); + + switch (e.key) { + case "Escape": + e.preventDefault(); + handleCancel(); + break; + case "Enter": + e.preventDefault(); + if (inputValue.trim() && inputValue.trim() !== asset.name) { + handleSave(); + } else { + handleCancel(); + } + break; + } + }, + [inputValue, asset.name, handleCancel, handleSave], + ); return ( - + {checkedEntities.includes(asset) ? ( )} - {asset.name} + {isRenaming ? ( + setInputValue(e.target.value)} + onKeyDown={handleInputKeydown} + autoFocus + > + ) : ( + {asset.name} + )} {children} ); } -export function EntitySidebarItem({ - asset, -}: { - asset: Asset & { children?: Asset[] }; -}) { + +export function EntitySidebarItem({ asset }: { asset: AssetNode }) { const { setEntity, entity, checkedEntities, setCheckedEntities } = useEntityStore(); const handleCheck = (e: React.MouseEvent) => { e.stopPropagation(); - if (checkedEntities.includes(asset)) { - setCheckedEntities(checkedEntities.filter((a) => a !== asset)); + + const allEntities = getAllEntities(asset); + const allChecked = allEntities.every((entity) => + checkedEntities.includes(entity), + ); + + if (allChecked) { + // If all entities are checked, uncheck them all + setCheckedEntities( + checkedEntities.filter((e) => !allEntities.includes(e)), + ); } else { - setCheckedEntities([...checkedEntities, asset]); + // If not all entities are checked, check them all + const newEntities = allEntities.filter( + (entity) => !checkedEntities.includes(entity), + ); + setCheckedEntities([...checkedEntities, ...newEntities]); } }; @@ -185,13 +281,42 @@ export function EntitySidebarProvider({ export function EntitySidebar() { const usemap = useUsemapStore((state) => state.usemap); + const deleteEntity = useUsemapStore((state) => state.deleteEntity); const { searchTerm, setSearchTerm } = useEntitySidebar(); + const { entity, checkedEntities, setCheckedEntities } = useEntityStore(); const tree = useFilteredAssetTree(usemap?.entities || [], searchTerm); + const handleDelete = useCallback(() => { + if (checkedEntities.length > 0) { + checkedEntities.forEach((e) => deleteEntity(e)); + } + console.log("Deleting entities:", checkedEntities); + setCheckedEntities([]); + }, [checkedEntities, tree, setCheckedEntities, deleteEntity]); + + const handleRename = useCallback(() => { + if (entity) { + // Start rename mode for selected entity + console.log("Renaming entity:", entity); + } + }, [entity]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case "Delete": + event.preventDefault(); + handleDelete(); + break; + } + }, + [handleDelete, handleRename], + ); + return ( - + diff --git a/frontend/hooks/useAssetTree.ts b/frontend/hooks/useAssetTree.ts index e3697ac..935b04f 100644 --- a/frontend/hooks/useAssetTree.ts +++ b/frontend/hooks/useAssetTree.ts @@ -1,18 +1,19 @@ +import { AssetNode } from "@/types/asset"; import { Asset } from "@/types/schemas/asset/Asset"; import fuzzysort from "fuzzysort"; import { useMemo } from "react"; -export function useEntityAssetTree(totalEntities: Asset[]): (Asset & { children?: Asset[] })[] { +export function useEntityAssetTree(totalEntities: Asset[]): AssetNode[] { const tree = useMemo(() => { - const childrenMap = new Map(); - totalEntities.forEach(entity => { + const childrenMap = new Map(); + totalEntities.forEach((entity) => { const key = entity.parent_id ?? -1; if (!childrenMap.has(key)) childrenMap.set(key, []); childrenMap.get(key)!.push(entity); }); - const buildTree = (parentId: number | null = -1): Asset[] & { children?: Asset[] }[] => { - return (childrenMap.get(parentId) || []).map((entity) => { + const buildTree = (parentId: number | null = -1): AssetNode[] => { + return (childrenMap.get(parentId) || []).map((entity): AssetNode => { return { ...entity, children: buildTree(entity.id), @@ -23,20 +24,19 @@ export function useEntityAssetTree(totalEntities: Asset[]): (Asset & { children? }, [totalEntities]); return tree; - } -export function useAssetTree(totalAssets: Asset[]): (Asset & { children?: Asset[] })[] { +export function useAssetTree(totalAssets: Asset[]): AssetNode[] { return useMemo(() => { - const childrenMap = new Map(); - totalAssets.forEach(asset => { + const childrenMap = new Map(); + totalAssets.forEach((asset) => { const key = asset.parent_id ?? -1; if (!childrenMap.has(key)) childrenMap.set(key, []); childrenMap.get(key)!.push(asset); }); - const buildTree = (parentId: number | null = -1): Asset[] & { children?: Asset[] }[] => { - return (childrenMap.get(parentId) || []).map(asset => ({ + const buildTree = (parentId: number | null = -1): AssetNode[] => { + return (childrenMap.get(parentId) || []).map((asset): AssetNode => ({ ...asset, children: buildTree(asset.id), })); @@ -49,12 +49,12 @@ export function useAssetTree(totalAssets: Asset[]): (Asset & { children?: Asset[ export function useFilteredAssetTree( totalAssets: Asset[], searchTerm: string, -): (Asset & { children?: Asset[] })[] { +): AssetNode[] { return useMemo(() => { const result = fuzzysort.go(searchTerm, totalAssets, { key: "name", all: true, - }) + }); const matched = result.map((r) => r.obj); const validIds = new Set(); @@ -70,24 +70,19 @@ export function useFilteredAssetTree( }); const filtered = totalAssets.filter((a) => validIds.has(a.id)); - const childrenMap = new Map< - number | null, - (Asset & { children?: Asset[] })[] - >(); + const childrenMap = new Map(); filtered.forEach((asset) => { const key = asset.parent_id ?? -1; if (!childrenMap.has(key)) childrenMap.set(key, []); childrenMap.get(key)!.push(asset); }); - const buildTree = ( - parentId: number | null = -1, - ): (Asset & { children?: Asset[] })[] => - (childrenMap.get(parentId) || []).map((asset) => ({ + const buildTree = (parentId: number | null = -1): AssetNode[] => + (childrenMap.get(parentId) || []).map((asset): AssetNode => ({ ...asset, children: buildTree(asset.id), })); return buildTree(-1); }, [totalAssets, searchTerm]); -} \ No newline at end of file +} diff --git a/frontend/store/entityStore.ts b/frontend/store/entityStore.ts index f6588a9..71a5bd9 100644 --- a/frontend/store/entityStore.ts +++ b/frontend/store/entityStore.ts @@ -1,11 +1,11 @@ -import { Asset } from "@/types/schemas/asset/Asset"; +import { AssetNode } from "@/types/asset"; import { create } from "zustand"; interface EntityStore { - entity: Asset | null; - setEntity: (entity: Asset) => void; - checkedEntities: Asset[] - setCheckedEntities: (entities: Asset[]) => void; + entity: AssetNode | null; + setEntity: (entity: AssetNode) => void; + checkedEntities: AssetNode[]; + setCheckedEntities: (entities: AssetNode[]) => void; } export const useEntityStore = create((set) => ({ diff --git a/frontend/store/mapStore.ts b/frontend/store/mapStore.ts index 56828d3..37e8df9 100644 --- a/frontend/store/mapStore.ts +++ b/frontend/store/mapStore.ts @@ -32,6 +32,7 @@ export type UsemapActions = { addEntity: (entity: Asset) => void; deleteEntity: (entity: Asset) => void; fetchUsemap: (mapName: string) => Promise; + updateEntityAssetName: (id: number, name: string) => void; updateEntity: (id: number, path: string[], value: any) => void; addAsset: (asset: Asset) => void; deleteAsset: (asset: Asset) => void; @@ -75,6 +76,14 @@ export const createUsemapStore = () => { // maybe can replaced with delete draft.entities[entity.id]? }), })), + updateEntityAssetName: (id: number, name: string) => + set((state) => ({ + usemap: produce(state.usemap, (draft: Usemap) => { + draft.entities = draft.entities.map((e) => + e.id === id ? { ...e, name: name } : e, + ); + }), + })), updateEntity: (id: number, path: string[], value: any) => set((state) => ({ usemap: produce(state.usemap, (draft: Usemap) => { diff --git a/frontend/types/asset.ts b/frontend/types/asset.ts index 8601c73..a824067 100644 --- a/frontend/types/asset.ts +++ b/frontend/types/asset.ts @@ -1,4 +1,5 @@ import { z } from "zod"; import { AssetSchema } from "./schemas/asset/Asset"; -export type Asset = z.infer & { data?: T | null }; \ No newline at end of file +export type Asset = z.infer & { data?: T | null }; +export type AssetNode = Asset & { children: AssetNode[] };