diff --git a/packages/design/src/components/tree/Tree.stories.tsx b/packages/design/src/components/tree/Tree.stories.tsx index 15aec975aa64..952a1f3739a7 100644 --- a/packages/design/src/components/tree/Tree.stories.tsx +++ b/packages/design/src/components/tree/Tree.stories.tsx @@ -17,6 +17,7 @@ import type { Meta } from '@storybook/react'; import React, { useState } from 'react'; +import type { ITreeNodeProps } from './Tree'; import { Tree, TreeSelectionMode } from './Tree'; const meta: Meta = { @@ -29,90 +30,87 @@ const meta: Meta = { }; export default meta; - -export const TreeBasic = { - render() { - const [value, setValue] = useState(); - - const data = [ +const data = [ + { + key: '0', + title: 'node 0', + children: [ + { key: '0-0', title: 'node 0-0' }, + { key: '0-1', title: 'node 0-1' }, + { + key: '0-2', + title: 'node 0-2', + children: [ + { key: '0-2-0', title: 'node 0-2-0' }, + { key: '0-2-1', title: 'node 0-2-1' }, + { key: '0-2-2', title: 'node 0-2-2' }, + ], + }, + { key: '0-3', title: 'node 0-3' }, + { key: '0-4', title: 'node 0-4' }, + { key: '0-5', title: 'node 0-5' }, + { key: '0-6', title: 'node 0-6' }, + { key: '0-7', title: 'node 0-7' }, + { key: '0-8', title: 'node 0-8' }, { - key: '0', - title: 'node 0', + key: '0-9', + title: 'node 0-9', children: [ - { key: '0-0', title: 'node 0-0' }, - { key: '0-1', title: 'node 0-1' }, + { key: '0-9-0', title: 'node 0-9-0' }, { - key: '0-2', - title: 'node 0-2', + key: '0-9-1', + title: 'node 0-9-1', children: [ - { key: '0-2-0', title: 'node 0-2-0' }, - { key: '0-2-1', title: 'node 0-2-1' }, - { key: '0-2-2', title: 'node 0-2-2' }, + { key: '0-9-1-0', title: 'node 0-9-1-0' }, + { key: '0-9-1-1', title: 'node 0-9-1-1' }, + { key: '0-9-1-2', title: 'node 0-9-1-2' }, + { key: '0-9-1-3', title: 'node 0-9-1-3' }, + { key: '0-9-1-4', title: 'node 0-9-1-4' }, ], }, - { key: '0-3', title: 'node 0-3' }, - { key: '0-4', title: 'node 0-4' }, - { key: '0-5', title: 'node 0-5' }, - { key: '0-6', title: 'node 0-6' }, - { key: '0-7', title: 'node 0-7' }, - { key: '0-8', title: 'node 0-8' }, { - key: '0-9', - title: 'node 0-9', + key: '0-9-2', + title: 'node 0-9-2', children: [ - { key: '0-9-0', title: 'node 0-9-0' }, - { - key: '0-9-1', - title: 'node 0-9-1', - children: [ - { key: '0-9-1-0', title: 'node 0-9-1-0' }, - { key: '0-9-1-1', title: 'node 0-9-1-1' }, - { key: '0-9-1-2', title: 'node 0-9-1-2' }, - { key: '0-9-1-3', title: 'node 0-9-1-3' }, - { key: '0-9-1-4', title: 'node 0-9-1-4' }, - ], - }, - { - key: '0-9-2', - title: 'node 0-9-2', - children: [ - { key: '0-9-2-0', title: 'node 0-9-2-0' }, - { key: '0-9-2-1', title: 'node 0-9-2-1' }, - ], - }, + { key: '0-9-2-0', title: 'node 0-9-2-0' }, + { key: '0-9-2-1', title: 'node 0-9-2-1' }, ], }, ], }, + ], + }, + { + key: '1', + title: 'node 1', + children: [ { - key: '1', - title: 'node 1', - // children: new Array(1000) - // .fill(null) - // .map((_, index) => ({ title: `auto ${index}`, key: `auto-${index}` })), + key: '1-0', + title: 'node 1-0', children: [ + { key: '1-0-0', title: 'node 1-0-0' }, { - key: '1-0', - title: 'node 1-0', + key: '1-0-1', + title: 'node 1-0-1', children: [ - { key: '1-0-0', title: 'node 1-0-0' }, - { - key: '1-0-1', - title: 'node 1-0-1', - children: [ - { key: '1-0-1-0', title: 'node 1-0-1-0' }, - { key: '1-0-1-1', title: 'node 1-0-1-1' }, - ], - }, - { key: '1-0-2', title: 'node 1-0-2' }, + { key: '1-0-1-0', title: 'node 1-0-1-0' }, + { key: '1-0-1-1', title: 'node 1-0-1-1' }, ], }, + { key: '1-0-2', title: 'node 1-0-2' }, ], }, - ]; + ], + }, +]; +export const TreeBasic = { + render() { + const [valueGroup, valueGroupSet] = useState([]); - function handleChange(value: string | number | boolean) { - setValue(value); + function handleSelected(node: ITreeNodeProps, result: ITreeNodeProps[]) { + valueGroupSet(result.map((e) => e.key)); + // eslint-disable-next-line no-console + console.log('all leafNode', node, result); } return ( @@ -120,8 +118,8 @@ export const TreeBasic = { data={data} defaultExpandAll selectionMode={TreeSelectionMode.ONLY_LEAF_NODE} - value={value} - onChange={handleChange} + valueGroup={valueGroup} + onChange={handleSelected} /> ); }, diff --git a/packages/design/src/components/tree/Tree.tsx b/packages/design/src/components/tree/Tree.tsx index d7b5d756f942..9d1826dc9805 100644 --- a/packages/design/src/components/tree/Tree.tsx +++ b/packages/design/src/components/tree/Tree.tsx @@ -14,11 +14,13 @@ * limitations under the License. */ -import { CheckMarkSingle, DropdownSingle } from '@univerjs/icons'; +import { DropdownSingle } from '@univerjs/icons'; import clsx from 'clsx'; import React, { useEffect, useMemo, useState } from 'react'; +import { Checkbox } from '../checkbox'; import styles from './index.module.less'; +import { createCacheWithFindNodePathFromTree, filterLeafNode, isIntermediated, mergeTreeSelected } from './util'; export enum TreeSelectionMode { ONLY_LEAF_NODE, @@ -51,87 +53,107 @@ export interface ITreeProps { selectionMode?: TreeSelectionMode; /** - * Used for setting the currently selected value + * Used for setting the currently selected value,leaf node. */ - value?: string | number | boolean; + valueGroup?: string[]; /** * Set the handler to handle `click` event */ - onChange?: (value: string | number | boolean) => void; + onChange?: (node: ITreeNodeProps, allSelectedNode: ITreeNodeProps[]) => void; + + onExpend?: (value: string) => void; } type TreeItemProps = ITreeNodeProps & { _selected?: boolean; _expand?: boolean; + _intermediated?: boolean; }; /** * Tree Component */ export function Tree(props: ITreeProps) { - const { data = [], defaultExpandAll = false, selectionMode = TreeSelectionMode.ALL, value, onChange } = props; - - const [expandKeys, setExpandKeys] = useState>([]); + const { data = [], defaultExpandAll = false, selectionMode = TreeSelectionMode.ALL, valueGroup = [], onChange, onExpend } = props; + const [update, forceUpdate] = useState({}); + const expandKeySet = useMemo(() => { + return new Set(); + }, [data]); + + const findNode = useMemo(() => createCacheWithFindNodePathFromTree(data), [data]); + + const selectedNodeKeySet = useMemo(() => { + const set = new Set(); + valueGroup.forEach((key) => { + const path = findNode.findNodePathFromTreeWithCache(key); + path.forEach((k) => set.add(k)); + }); + return set; + }, [valueGroup, findNode]); useEffect(() => { function walkData(item: ITreeNodeProps) { - setExpandKeys((prev) => [...prev, item.key]); - + expandKeySet.add(item.key); item.children?.forEach(walkData); } if (defaultExpandAll) { data.forEach(walkData); } + forceUpdate({}); }, [defaultExpandAll, data]); const computedData = useMemo(() => { - return data.map(function walkData(item): TreeItemProps { - const { title, key, children } = item; - - return { - title, - key, - children: children && children.map(walkData), - _selected: key === value, - _expand: expandKeys.includes(key), - }; + return data.map((item) => { + function walkData(item: ITreeNodeProps): TreeItemProps { + const { title, key, children } = item; + const isExpand = expandKeySet.has(key); + const isSelected = selectedNodeKeySet.has(key); + const intermediated = isIntermediated(selectedNodeKeySet, item); + + return { + title, + key, + children: children && children.map((item) => walkData(item)), + _selected: isSelected, + _expand: isExpand, + _intermediated: intermediated, + }; + } + return walkData(item); }); - }, [value, expandKeys]); - - function handleSelectItem(treeItem: ITreeNodeProps) { - if (treeItem.children) { - setExpandKeys((prev) => { - const index = prev.findIndex((key) => key === treeItem.key); + }, [selectedNodeKeySet, expandKeySet, update]); - if (index === -1) { - return [...prev, treeItem.key]; - } + function handleChange(treeItem: TreeItemProps) { + const path: string[] = findNode.findNodePathFromTreeWithCache(treeItem.key); + const result = mergeTreeSelected(data, [...selectedNodeKeySet], path); + onChange?.(treeItem, filterLeafNode(data, result)); + } - return [...prev.slice(0, index), ...prev.slice(index + 1)]; - }); + function handleExpendItem(treeItem: ITreeNodeProps) { + if (treeItem.children?.length) { + if (expandKeySet.has(treeItem.key)) { + expandKeySet.delete(treeItem.key); + } else { + expandKeySet.add(treeItem.key); + } + forceUpdate({}); } - if (selectionMode === TreeSelectionMode.ONLY_LEAF_NODE) { if (treeItem.children) { return; } } - onChange?.(treeItem.key); + onExpend?.(treeItem.key); } function walkTree(treeItem: TreeItemProps) { - const { title, key, children, _selected, _expand } = treeItem; - + const { title, key, children, _selected, _expand, _intermediated } = treeItem; return (
  • { - e.stopPropagation(); - handleSelectItem(treeItem); - }} > { + e.stopPropagation(); + handleExpendItem(treeItem); + }} > )} - {_selected && ( - - - - )} - {title} + { + handleChange(treeItem); + }} + /> + { + e.stopPropagation(); + handleExpendItem(treeItem); + }} + > + {title} + {children && (
      { + const data = [ + { + key: '0', + title: 'node 0', + children: [ + { key: '0-0', title: 'node 0-0' }, + { key: '0-1', title: 'node 0-1' }, + ], + }, + ]; + it('test findNodePathFromTree', () => { + expect(findNodePathFromTree(data, '0-0')).toEqual(['0', '0-0']); + expect(findNodePathFromTree(data, '2-0')).toEqual([]); + }); + + it('test findSubTreeFromPath', () => { + expect(findSubTreeFromPath(data, ['0'])).toEqual([ + { key: '0-0', title: 'node 0-0' }, + { key: '0-1', title: 'node 0-1' }, + ]); + expect(findSubTreeFromPath(data, [])).toEqual(data); + }); + + it('test findNodeFromPath', () => { + expect(findNodeFromPath(data, ['0'])).toEqual({ + key: '0', + title: 'node 0', + children: [ + { key: '0-0', title: 'node 0-0' }, + { key: '0-1', title: 'node 0-1' }, + ], + }); + expect(findNodeFromPath(data, [])).toEqual(undefined); + }); + + it('test mergeTreeSelected', () => { + expect(mergeTreeSelected(data, [], ['0', '0-0'])).toEqual(['0', '0-0']); + }); + + it('test isIntermediated', () => { + expect(isIntermediated(new Set(['0-0', '0']), { + key: '0', + title: 'node 0', + children: [ + { key: '0-0', title: 'node 0-0' }, + { key: '0-1', title: 'node 0-1' }, + ], + })).toBeTruthy(); + expect(isIntermediated(new Set(['0-1', '0-0', '0']), { + key: '0', + title: 'node 0', + children: [ + { key: '0-0', title: 'node 0-0' }, + { key: '0-1', title: 'node 0-1' }, + ], + })).toBeFalsy(); + }); + it('defaultExpandAll', async () => { const { container } = render( a { - cursor: pointer; position: relative; - display: block; + display: flex; + align-items: center; + cursor: pointer; } &-content { margin-bottom: 8px; &-selected { - font-weight: 500; + // font-weight: 500; color: rgb(var(--primary-color)); &-icon { diff --git a/packages/design/src/components/tree/index.ts b/packages/design/src/components/tree/index.ts index a93b37f9f9ad..cf2d392f0046 100644 --- a/packages/design/src/components/tree/index.ts +++ b/packages/design/src/components/tree/index.ts @@ -15,3 +15,4 @@ */ export { type ITreeNodeProps, type ITreeProps, Tree, TreeSelectionMode } from './Tree'; +export { mergeTreeSelected, findSubTreeFromPath, findNodePathFromTree, filterLeafNode } from './util'; diff --git a/packages/design/src/components/tree/util.ts b/packages/design/src/components/tree/util.ts new file mode 100644 index 000000000000..267d008eace1 --- /dev/null +++ b/packages/design/src/components/tree/util.ts @@ -0,0 +1,163 @@ +/** + * Copyright 2023-present DreamNum Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ITreeNodeProps } from './Tree'; + +export const findNodePathFromTree = (tree: ITreeNodeProps[], key: string) => { + const result: string[] = []; + const recursive = (node: ITreeNodeProps) => { + result.push(node.key); + if (key === node.key) { + return true; + } + if (node.children?.length) { + if (node.children.some(recursive)) { + return true; + } + } + result.pop(); + }; + tree.some(recursive); + return result; +}; +export const createCacheWithFindNodePathFromTree = (tree: ITreeNodeProps[]) => { + const cache = new Map(); + let cacheTree = tree; + return { + findNodePathFromTreeWithCache: (key: string) => { + const cacheValue = cache.get(key); + if (cacheValue) { + return cacheValue; + } + const path = findNodePathFromTree(cacheTree, key); + const pathList = path.map((k, index, arr) => { + const result: string[] = []; + for (let i = 0; i <= index; i++) { + result.push(arr[i]); + } + return result; + }).reverse(); + pathList.forEach((list) => { + const key = list[list.length - 1]; + cache.set(key, list); + }); + return path; + }, + reset: (newTree?: ITreeNodeProps[]) => { + cache.clear(); + if (newTree) { + cacheTree = newTree; + } + }, + }; +}; + +export const findSubTreeFromPath = (tree: ITreeNodeProps[], path: string[]) => { + if (!path.length) { + return tree; + } + return path.reduce((list, key) => { + const item = list.find((node) => node.key === key); + return item?.children || []; + }, tree); +}; + +export const findNodeFromPath = (tree: ITreeNodeProps[], _path: string[]) => { + const path = _path.slice(0); + const key = path.pop(); + const list = findSubTreeFromPath(tree, path); + return list.find((node) => node.key === key); +}; + +export const mergeTreeSelected = (treeData: ITreeNodeProps[], treeSelected: string[], path: string[]) => { + const set = new Set(treeSelected); + const key = path[path.length - 1]; + const subTree = findSubTreeFromPath(treeData, path); + if (!set.has(key)) { + const addRecursive = (node: ITreeNodeProps) => { + set.add(node.key); + if (node.children) { + node.children.forEach((n) => addRecursive(n)); + } + }; + path.forEach((k) => set.add(k)); + if (subTree.length) { + subTree.forEach((node) => addRecursive(node)); + } + } else { + if (subTree.length) { + const deleteRecursive = (node: ITreeNodeProps) => { + set.delete(node.key); + if (node.children) { + node.children.forEach((n) => deleteRecursive(n)); + } + }; + subTree.forEach((node) => { deleteRecursive(node); }); + } + const pathList = path.map((k, index, arr) => { + const result: string[] = []; + for (let i = 0; i <= index; i++) { + result.push(arr[i]); + } + return result; + }).reverse(); + pathList.some((path) => { + const list = findSubTreeFromPath(treeData, path); + const key = path[path.length - 1]; + if (list.every((e) => !set.has(e.key))) { + set.delete(key); + } else { + return true; + } + return false; + }); + } + return [...set]; +}; + +export const isIntermediated = (treeSelected: Set, node: ITreeNodeProps) => { + const list = node.children || []; + const checkIsSelected = (node: ITreeNodeProps) => { + if (node.children?.length) { + const isAllChecked = node.children.every(checkIsSelected); + if (isAllChecked) { + return true; + } + return false; + } + return treeSelected.has(node.key); + }; + + if (list.length) { + return list.some((node) => !checkIsSelected(node)); + } + return false; +}; + +export const filterLeafNode = (tree: ITreeNodeProps[], keyList: string[]) => { + const result: ITreeNodeProps[] = []; + const find = createCacheWithFindNodePathFromTree(tree); + keyList.forEach((key) => { + const path = find.findNodePathFromTreeWithCache(key); + const node = findNodeFromPath(tree, path); + if (node) { + if (!node.children?.length) { + result.push(node); + } + } + }); + return result; +}; diff --git a/packages/design/src/index.ts b/packages/design/src/index.ts index f30dd26dc442..d0e86d34a1b4 100644 --- a/packages/design/src/index.ts +++ b/packages/design/src/index.ts @@ -56,7 +56,7 @@ export { type ISelectListProps, SelectList } from './components/select-list'; export { type ISegmentedProps, Segmented } from './components/segmented'; export { type ISliderProps, Slider } from './components/slider'; export { type ITooltipProps, Tooltip, resizeObserverCtor } from './components/tooltip'; -export { type ITreeNodeProps, type ITreeProps, Tree, TreeSelectionMode } from './components/tree'; +export { type ITreeNodeProps, type ITreeProps, Tree, TreeSelectionMode, mergeTreeSelected, findSubTreeFromPath, findNodePathFromTree, filterLeafNode } from './components/tree'; export { enUS, zhCN, ruRU } from './locale'; export { type ILocale } from './locale/interface'; export { defaultTheme, greenTheme, themeInstance } from './themes';