@ant-design/x
Version:
Craft AI-driven interfaces effortlessly
326 lines (303 loc) • 11.2 kB
JavaScript
import { useControlledState } from '@rc-component/util';
import { Flex, Splitter } from 'antd';
import { clsx } from 'clsx';
import React, { useCallback, useEffect, useState } from 'react';
import useProxyImperativeHandle from "../_util/hooks/use-proxy-imperative-handle";
import useXComponentConfig from "../_util/hooks/use-x-component-config";
import { useLocale } from "../locale";
import enUS from "../locale/en_US";
import { useXProviderContext } from "../x-provider";
import DirectoryTree from "./DirectoryTree";
import FilePreview from "./FilePreview";
import useStyle from "./style";
// File content service interface
// Folder properties
// Ref interface type
const ForwardFolder = /*#__PURE__*/React.forwardRef((props, ref) => {
const {
prefixCls: customizePrefixCls,
className,
classNames,
styles,
style,
treeData,
directoryIcons,
previewRender,
directoryTitle,
previewTitle,
selectable = true,
defaultSelectedFile,
defaultExpandAll = true,
selectedFile,
onSelectedFileChange,
directoryTreeWith = 278,
emptyRender,
defaultExpandedPaths,
expandedPaths,
onExpandedPathsChange,
onFileClick,
onFolderClick,
contextMenu,
onRightClick
} = props;
// ============================= Refs =============================
const containerRef = React.useRef(null);
// ============================ Tree Helpers ============================
/** Find a node by path segments (read from props.treeData) */
const getNodeByPath = useCallback(path => {
if (!path || path.length === 0 || !treeData) return undefined;
const findNode = (nodes, index = 0) => {
if (index >= path.length) return undefined;
const segment = path[index];
for (const node of nodes) {
if (node.path === segment) {
return index === path.length - 1 ? node : node.children ? findNode(node.children, index + 1) : undefined;
}
}
return undefined;
};
return findNode(treeData);
}, [treeData]);
/** Immutable walk for update / delete / add */
const walkTree = useCallback((nodes, path, index, action, data) => {
const targetSegment = path[index];
const isLast = index === path.length - 1;
return nodes.map(node => {
if (node.path !== targetSegment) return node;
if (isLast) {
switch (action) {
case 'update':
return {
...node,
...data
};
case 'delete':
return null;
case 'add':
{
const newChild = data;
const children = node.children ? [...node.children, newChild] : [newChild];
return {
...node,
children
};
}
default:
return node;
}
}
if (node.children) {
const newChildren = walkTree(node.children, path, index + 1, action, data);
return {
...node,
children: newChildren.filter(Boolean)
};
}
return node;
});
}, []);
useProxyImperativeHandle(ref, () => ({
nativeElement: containerRef.current,
getNode: path => getNodeByPath(path),
updateNode: (path, data) => treeData ? walkTree(treeData, path, 0, 'update', data) : [],
deleteNode: path => treeData ? walkTree(treeData, path, 0, 'delete').filter(Boolean) : [],
addNode: (parentPath, node) => treeData ? walkTree(treeData, parentPath, 0, 'add', node) : []
}));
// ============================ State ============================
// Find node and validate path
const findNodeAndValidate = useCallback((path, validateAsFile = false) => {
if (!path) return {
node: undefined,
isValid: false
};
const segments = Array.isArray(path) ? path.filter(Boolean) : path.split('/').filter(Boolean);
if (segments.length === 0) return {
node: undefined,
isValid: false
};
const findNode = (nodes, index = 0) => {
if (index >= segments.length) return undefined;
const currentSegment = segments[index];
for (const node of nodes) {
if (node.path === currentSegment) {
return index === segments.length - 1 ? node : node.children ? findNode(node.children, index + 1) : undefined;
}
}
return undefined;
};
const node = findNode(treeData);
const isValid = validateAsFile ? !!node && (!node?.children || node.children.length === 0) : !!node;
return {
node,
isValid
};
}, [treeData]);
const [validSelectedFile, setValidSelectedFile] = useState(false);
const isValidSelectedFile = filePath => !!(filePath && filePath.length > 0 && findNodeAndValidate(filePath, true).isValid);
const [expandedPathsState, setExpandedPaths] = useControlledState(defaultExpandedPaths, expandedPaths);
const [selectedFileState, setSelectedFileState] = useControlledState(isValidSelectedFile(defaultSelectedFile || []) ? defaultSelectedFile || [] : [], selectedFile);
useEffect(() => {
const isValid = isValidSelectedFile(selectedFile || defaultSelectedFile || []);
setValidSelectedFile(isValid);
}, [selectedFile, treeData, defaultSelectedFile]);
const [fileContent, setFileContent] = useState('');
const [loadingContent, setLoadingContent] = useState(false);
// ============================ Prefix ============================
const {
getPrefixCls,
direction
} = useXProviderContext();
const prefixCls = getPrefixCls('folder', customizePrefixCls);
const [hashId, cssVarCls] = useStyle(prefixCls);
const contextConfig = useXComponentConfig('folder');
const [locale] = useLocale('Folder', enUS.Folder);
// ============================ Style ============================
const mergedCls = clsx(prefixCls, contextConfig.className, className, classNames?.root, hashId, cssVarCls, {
[`${prefixCls}-rtl`]: direction === 'rtl',
[`${prefixCls}-selectable`]: selectable
});
// ============================ Event Handlers ============================
const handleSelect = (_keys, info) => {
const keys = _keys;
const nodes = Array.isArray(info.selectedNodes) ? info.selectedNodes : [info.selectedNodes];
// Check if a folder was clicked
const isFolder = nodes.some(node => !node.isLeaf);
if (isFolder) {
// Click folder: don't update selectedFileState, only trigger folder click event
if (nodes.length === 1) {
const node = nodes[0];
onFolderClick?.(node.path);
}
return;
}
// Convert full path to array format
const pathArray = keys[0]?.split('/').filter(Boolean) || [];
// Avoid empty or invalid paths
if (pathArray.length === 0) return;
// Get selected file name and content (single file selection)
const selectedNode = nodes[0];
const fileName = selectedNode?.title;
const fileContent = selectedNode?.content;
// Trigger selection change callback (main interaction method)
onSelectedFileChange?.({
path: pathArray,
title: fileName,
content: fileContent
});
// // Update internal state in uncontrolled mode
const isControlled = selectedFile !== undefined;
if (!isControlled) {
setValidSelectedFile(true);
setSelectedFileState(pathArray);
}
// Handle single file click event
if (nodes.length === 1) {
const node = nodes[0];
onFileClick?.(node.path, node.content);
}
};
const handleExpand = keys => {
const newPaths = keys;
setExpandedPaths(newPaths);
onExpandedPathsChange?.(newPaths);
};
// ============================ Effects ============================
useEffect(() => {
const loadFileContent = async () => {
if (!validSelectedFile || selectedFileState.length === 0) {
setFileContent('');
setLoadingContent(false);
return;
}
const filePath = selectedFileState.join('/');
// First check if the node already has content
const segments = filePath.split('/').filter(segment => segment !== '');
const {
node
} = findNodeAndValidate(segments);
// If file content service is available, use it to load content
if (props.fileContentService) {
setLoadingContent(true);
try {
const content = await props.fileContentService.loadFileContent(filePath);
setFileContent(content);
} catch (error) {
setFileContent(`// ${locale?.loadError}: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setLoadingContent(false);
}
} else if (node?.content) {
// If node already has content, use it directly
setFileContent(node.content);
setLoadingContent(false);
return;
} else {
// No file content service, show prompt message
setFileContent(`// ${locale.noService}`);
setLoadingContent(false);
}
};
loadFileContent();
}, [validSelectedFile, selectedFileState, treeData, props.fileContentService, findNodeAndValidate]);
// ============================ Style ============================
const mergedStyle = {
...contextConfig.style,
...styles?.root,
...style
};
return /*#__PURE__*/React.createElement("div", {
ref: containerRef,
className: mergedCls,
style: mergedStyle
}, /*#__PURE__*/React.createElement(Flex, {
className: `${prefixCls}-container`
}, /*#__PURE__*/React.createElement(Splitter, null, /*#__PURE__*/React.createElement(Splitter.Panel, {
defaultSize: directoryTreeWith
}, /*#__PURE__*/React.createElement("div", {
className: clsx(`${prefixCls}-directory-tree`, classNames?.directoryTree),
style: {
...contextConfig.styles?.directoryTree,
...styles?.directoryTree
}
}, /*#__PURE__*/React.createElement(DirectoryTree, {
directoryIcons: directoryIcons,
prefixCls: customizePrefixCls,
treeData: treeData,
selectedKeys: selectable && selectedFileState && validSelectedFile ? [selectedFileState.join('/')] : [],
classNames: classNames,
styles: styles,
expandedKeys: expandedPathsState,
onSelect: handleSelect,
onExpand: handleExpand,
defaultExpandAll: defaultExpandAll,
directoryTitle: directoryTitle,
contextMenu: contextMenu,
onRightClick: onRightClick
}))), /*#__PURE__*/React.createElement(Splitter.Panel, null, /*#__PURE__*/React.createElement(FilePreview, {
emptyRender: emptyRender,
prefixCls: customizePrefixCls,
classNames: classNames,
styles: styles,
selectedFile: validSelectedFile ? selectedFileState : [],
fileContent: fileContent,
loading: loadingContent,
previewTitle: previewTitle,
previewRender: previewRender,
getFileNode: path => {
if (!path || path.length === 0) return undefined;
const {
node
} = findNodeAndValidate(path);
return node ? {
title: node.title,
path: node.path,
content: node.content
} : undefined;
}
})))));
});
const Folder = ForwardFolder;
if (process.env.NODE_ENV !== 'production') {
Folder.displayName = 'Folder';
}
export default Folder;