diff --git a/packages/raga-app/src/client/components/common/tree.module.scss b/packages/raga-app/src/client/components/common/tree.module.scss index e74b7b2e..b5f15b3f 100644 --- a/packages/raga-app/src/client/components/common/tree.module.scss +++ b/packages/raga-app/src/client/components/common/tree.module.scss @@ -38,3 +38,7 @@ background-color: var(--mantine-primary-color-light-hover); } } + +.expandIcon { + margin-left: 2px; +} diff --git a/packages/raga-app/src/client/components/common/tree.tsx b/packages/raga-app/src/client/components/common/tree.tsx index 282f570c..ae3a0304 100644 --- a/packages/raga-app/src/client/components/common/tree.tsx +++ b/packages/raga-app/src/client/components/common/tree.tsx @@ -46,9 +46,109 @@ export interface ControlledTreeProps { onSelect?: (nodes: TreeNode[]) => void; } +interface MantineTreeController { + isNodeChecked: (value: string) => boolean; + isNodeIndeterminate: (value: string) => boolean; + uncheckNode: (value: string) => void; + checkNode: (value: string) => void; + collapse: (value: string) => void; + expand: (value: string) => void; + deselect: (value: string) => void; + select: (value: string) => void; +} + +interface TreeNodeRendererProps { + node: TreeNodeData; + expanded: boolean; + selected: boolean; + hasChildren: boolean; + elementProps: { + className?: string; + [key: string]: unknown; + }; + tree: MantineTreeController; + selectionMode: TreeSelectionMode; +} + // COMPONENTS // ------------------------------------------------------------------------------------------------- +const TreeNodeRenderer = memo(function TreeNodeRendererImpl({ + node, + expanded, + selected, + hasChildren, + elementProps, + tree, + selectionMode, +}: TreeNodeRendererProps) { + const checked = tree.isNodeChecked(node.value); + const indeterminate = tree.isNodeIndeterminate(node.value); + + const handleCheckboxClick = useCallback(() => { + if (checked) { + tree.uncheckNode(node.value); + } else { + tree.checkNode(node.value); + } + }, [checked, node.value, tree]); + + const handleExpandClick = useCallback(() => { + if (expanded) { + tree.collapse(node.value); + } else { + tree.expand(node.value); + } + }, [expanded, node.value, tree]); + + const handleNodeClick = useCallback(() => { + if (selectionMode !== "single") { + return; + } + + if (selected) { + tree.deselect(node.value); + } else { + tree.select(node.value); + } + }, [node.value, selected, selectionMode, tree]); + + return ( +
+ {selectionMode === "multiple" && ( + + )} + + {hasChildren && ( + + {expanded ? : } + + )} + +
+ {node.label} +
+
+ ); +}); + /** * Wrapper around Mantine's Tree component with controlled state management. * @@ -97,109 +197,52 @@ function ControlledTree({ return path; }, [mantineNodes, nodes, selectedNodes, selectedMantineNodes]); - const tree = useTree({ - initialExpandedState: getTreeExpandedState(mantineNodes, pathToFirstSelectedNode), - initialSelectedState: selectedMantineNodes.map((node) => node.value), - }); + // Initialize tree with memoized initial state + const initialTreeState = useMemo( + () => ({ + initialExpandedState: getTreeExpandedState(mantineNodes, pathToFirstSelectedNode), + initialSelectedState: selectedMantineNodes.map((node) => node.value), + }), + [mantineNodes, pathToFirstSelectedNode, selectedMantineNodes], + ); + + const tree = useTree(initialTreeState); const { select, clearSelected, checkedState } = tree; const allNodesChecked = checkedState.length === numLeafNodes; const someNodesChecked = checkedState.length > 0; - useEffect(() => { - if (selectionMode === "multiple") { - onSelect?.( - filterUndefined( - checkedState.map((nodeId) => findNodeById(nodes, mantineNodeValueToId(nodeId))), - ), - ); - } - }, [checkedState, nodes, onSelect, selectionMode]); - const renderTreeNode = useCallback( - ({ node, expanded, selected, hasChildren, elementProps, tree }: RenderTreeNodePayload) => { - const checked = tree.isNodeChecked(node.value); - const indeterminate = tree.isNodeIndeterminate(node.value); - - return ( -
- {selectionMode === "multiple" && ( - { - if (checked) { - tree.uncheckNode(node.value); - } else { - tree.checkNode(node.value); - } - }} - /> - )} - - {hasChildren && ( - { - if (expanded) { - tree.collapse(node.value); - } else { - tree.expand(node.value); - } - }} - > - {expanded ? : } - - )} - -
{ - if (selectionMode !== "single") { - return; - } - - if (selected) { - tree.deselect(node.value); - } else { - tree.select(node.value); - const selectedNode = findNodeById(nodes, mantineNodeValueToId(node.value)); - if (selectedNode) { - onSelect?.([selectedNode]); - } - } - }} - > - {node.label} -
-
- ); + const handleSelectionChange = useCallback( + (nodes: TreeNode[]) => { + if (selectionMode === "multiple") { + onSelect?.( + filterUndefined( + checkedState.map((nodeId) => findNodeById(nodes, mantineNodeValueToId(nodeId))), + ), + ); + } }, - [nodes, onSelect, selectionMode], + [checkedState, onSelect, selectionMode], ); + useEffect(() => { + handleSelectionChange(nodes); + }, [handleSelectionChange, nodes]); + // Update tree state controlled selection changes useEffect(() => { if (selectionMode === "single") { clearSelected(); const selectedNode = selectedMantineNodes[0]; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (selectedNode) { - select(selectedNode.value); - } + select(selectedNode.value); } }, [selectedMantineNodes, select, clearSelected, selectionMode]); + const renderTreeNode = useCallback( + (props: RenderTreeNodePayload) => , + [selectionMode], + ); + return ( <> {selectionMode === "multiple" && ( @@ -237,22 +280,25 @@ export default memo(ControlledTree) as typeof ControlledTree; // UTILITIES // ------------------------------------------------------------------------------------------------- -function mapNodesToMantineFormat( +const nodeValuePathsMap = new WeakMap[], Map>(); + +function mapNodesToMantineFormat( nodes: TreeNode[], - /** IDs of the selected node(s). */ selectedNodeIds: string[], - /** - * Accumulated map of node id to node value path. - * Example: - * - node 1: "1" - * - node 2: "1/2" - * - node 3: "1/2/3" - */ - nodeValuePaths: Map = new Map(), + nodeValuePaths: Map = nodeValuePathsMap.get(nodes) ?? new Map(), ): { mantineNodes: TreeNodeData[]; numLeafNodes: number } { + // Cache the nodeValuePaths map for this nodes array + if (!nodeValuePathsMap.has(nodes)) { + nodeValuePathsMap.set(nodes, nodeValuePaths); + } + let numLeafNodes = 0; function getNodeValue(node: TreeNode) { + // Check cache first + const cachedValue = nodeValuePaths.get(node.id); + if (cachedValue) return cachedValue; + const parentNodeValue = node.parentId ? nodeValuePaths.get(node.parentId) : undefined; const value = parentNodeValue ? `${parentNodeValue}/${node.id}` : node.id; nodeValuePaths.set(node.id, value); diff --git a/packages/raga-app/src/client/components/library/audioFilesServerControls.module.scss b/packages/raga-app/src/client/components/library/audioFilesServerControls.module.scss new file mode 100644 index 00000000..8f4532d7 --- /dev/null +++ b/packages/raga-app/src/client/components/library/audioFilesServerControls.module.scss @@ -0,0 +1,3 @@ +.input { + min-width: 300px; +} diff --git a/packages/raga-app/src/client/components/library/audioFilesServerControls.tsx b/packages/raga-app/src/client/components/library/audioFilesServerControls.tsx index 5cc75a6c..79355e8c 100644 --- a/packages/raga-app/src/client/components/library/audioFilesServerControls.tsx +++ b/packages/raga-app/src/client/components/library/audioFilesServerControls.tsx @@ -25,6 +25,7 @@ import { useInterval } from "usehooks-ts"; import { AUDIO_FILES_SERVER_PING_INTERVAL } from "../../../common/constants"; import { appStore } from "../../store/appStore"; +import styles from "./audioFilesServerControls.module.scss"; export default function AudioFilesServerControls() { const status = appStore.use.audioFilesServerStatus(); @@ -92,7 +93,7 @@ export default function AudioFilesServerControls() { value={rootFolder} onChange={handleRootFolderInputChange} color={status === "failed" ? "red" : status === "started" ? "green" : undefined} - style={{ minWidth: 300 }} + className={styles.input} size="sm" label="Root folder" /> diff --git a/packages/raga-app/src/client/components/playlistTable/playlistTable.tsx b/packages/raga-app/src/client/components/playlistTable/playlistTable.tsx index a6fd71f0..4c8baf72 100644 --- a/packages/raga-app/src/client/components/playlistTable/playlistTable.tsx +++ b/packages/raga-app/src/client/components/playlistTable/playlistTable.tsx @@ -46,6 +46,7 @@ function PlaylistTable({ log.debug(`[client] selected playlist ${firstNode.id}: '${firstNode.data.Name}'`); onSelect?.([firstNode.id]); } else if (selectionMode === "multiple") { + log.debug(`[client] selected ${nodes.length.toString()} playlists`); onSelect?.(nodes.map((n) => n.id)); } },