import React, { useState, useMemo } from "react"; import warning from "warning"; import { ControlledProps, useDefaultValue } from "../form/controlled"; import { CheckContext, CheckChangeContext, CheckProps } from "../check"; export interface CheckTreeChangeContext extends CheckChangeContext { /** * 连选相关信息 */ shiftSelect?: { /** * 起始节点名 */ startName: string; /** * 结束 */ endName: string; }; } export type ValueMode = "all" | "onlyLeaf" | "parentFirst"; export interface CheckTreeProps extends ControlledProps { /** * 已勾选的叶子节点的 name 集合 */ value?: string[]; /** * 勾选的节点变更时回调 */ onChange?: (value: string[], context: CheckTreeChangeContext) => void; /** * 提供树的节点关系,每个子节点对应其父节点 */ relations: CheckTreeRelation; /** * 组件树内容,内部的 状态将被托管 */ children?: React.ReactNode; /** * 禁用的控件 name */ disabledNames?: string[]; /** * 是否支持按 Shift 快捷键时多选 * * - `"virtualized"` - 适配虚拟滚动,`onChange` 不直接返回变更后的 `value` * * @default true */ shiftSelect?: boolean | "virtualized"; /** * 结点选择值的描述方式 * * - `"all"` - 使用全部层级 * - `"onlyLeaf"` - 仅使用子节点 * - `"parentFirst"` - 当子节点全部选中时,仅使用父节点 * * @default "onlyLeaf" * @since 2.7.0 */ valueMode?: ValueMode; // /** // * disabled 的组件是否从全选的范围内排除 // * @default true // */ // excludeDisabledFromParent?: boolean; } /** * CheckTree 关系定义 interface ```ts interface CheckTreeRelation { [child: string]: string; } ``` */ export interface CheckTreeRelation { [child: string]: string; } /** * CheckTree 转换 * * 节点 -> 全部选中节点 */ export function getAllIds( selectedIds: string[], parentMap: Map, childrenMap: Map> ): string[] { const allIds: string[] = []; const nodeSet = new Set(selectedIds); // 遍历当前节点下子树 const traverse = (id: string) => { // 待检查状态 let checked = true; // 当前状态 const selfChecked = nodeSet.has(id); const children = childrenMap.get(id); if (!children) { // 叶子节点只需要判断自身 checked = selfChecked; } else { children.forEach(cid => { // 如果父节点已经选中则直接选中其子节点 if (selfChecked) { nodeSet.add(cid); } traverse(cid); if (!nodeSet.has(cid)) { checked = false; } }); } if (checked) { nodeSet.add(id); allIds.push(id); } }; for (const [id] of childrenMap) { // 根节点 if (!parentMap.has(id)) { traverse(id); } } // 单层无树结构场景 for (const id of selectedIds) { if (!parentMap.has(id)) { traverse(id); } } return allIds; } /** * 值模式转换 */ export function useValueMode( rawValue: string[] = [], rawOnChange: (value: string[], context?: any) => void, parentMap: Map, childrenMap: Map>, mode: ValueMode = "onlyLeaf" ): [string[], (value: string[], context?: any) => void] { return useMemo(() => { const all = getAllIds(rawValue, parentMap, childrenMap); if (mode === "all") { const value = []; all.forEach(id => { // 叶节点 if (!childrenMap.get(id)) { value.push(id); } }); const onChange = (value, ...args) => { rawOnChange(getAllIds(value, parentMap, childrenMap), ...args); }; return [value, onChange]; } if (mode === "parentFirst") { const value = []; all.forEach(id => { // 叶节点 if (!childrenMap.get(id)) { value.push(id); } }); const onChange = (onlyLeafValue, ...args) => { const all = getAllIds(onlyLeafValue, parentMap, childrenMap); const allSet = new Set(all); const invalidSet = new Set(); all.forEach(id => { // 父节点此刻被选中则该节点无效 if (parentMap.has(id) && allSet.has(parentMap.get(id))) { invalidSet.add(id); } }); rawOnChange( all.filter(id => !invalidSet.has(id)), ...args ); }; return [value, onChange]; } // onlyLeaf const value = all.filter(i => { return !childrenMap.has(i); }); const onChange = rawOnChange; return [value, onChange]; }, [childrenMap, mode, parentMap, rawOnChange, rawValue]); } export function getRelationMap(relations: CheckTreeRelation) { // 子节点 -> 父节点 const parentMap = new Map(); // 父节点 -> 子节点组 const childrenMap = new Map>(); for (const [child, parent] of Object.entries(relations)) { parentMap.set(child, parent); let childrenSet = childrenMap.get(parent); if (!childrenSet) { childrenSet = new Set(); childrenMap.set(parent, childrenSet); } childrenSet.add(child); } return { parentMap, childrenMap }; } export function CheckTree(props: CheckTreeProps) { const { relations, children, shiftSelect = true, disabledNames = [], valueMode = "onlyLeaf", ...restProps } = useDefaultValue(props); const [prevCheck, setPrevCheck] = useState(null); const { parentMap, childrenMap } = useMemo(() => getRelationMap(relations), [ relations, ]); // 模式转换后的 value/onChange const [value, onChange] = useValueMode( restProps.value, restProps.onChange, parentMap, childrenMap, valueMode ); const disabledSet = useMemo( () => completeDisabledNames(disabledNames, childrenMap), [childrenMap, disabledNames] ); const model = useMemo( () => getTreeModel( parentMap, childrenMap, value, disabledSet, shiftSelect, prevCheck, setPrevCheck ), [childrenMap, disabledSet, parentMap, prevCheck, shiftSelect, value] ); return ( will ignore the "value" prop' ); // [TODO] Table seclectable // warning( // typeof checkProps.disabled === 'undefined', // 'Component managed by will ignore the "disabled" prop' // ); model.visit(checkProps); const checkName = checkProps.name; return { ...model.getNodeState(checkName), disabled: disabledSet.has(checkProps.name), // 支持 checkbox 上的 onChange 处理时阻止默认的处理行为 onChange: (checked, context) => { if (typeof checkProps.onChange === "function") { checkProps.onChange(checked, context); if (context.event.defaultPrevented) { return; } } const [selection, changeContext = {}] = model.handleChange( checkProps.name, checked, context.event ); if (typeof onChange === "function") { onChange(selection, { ...context, ...changeContext }); } }, ...checkProps, }; }, }} > {children} ); } export function completeDisabledNames( names: string[], childrenMap: Map> ): Set { const nameSet = new Set(names); for (let i = 0; i < names.length; ++i) { const childrenSet = childrenMap.get(names[i]); if (childrenSet) { childrenSet.forEach(child => { if (!nameSet.has(child)) { nameSet.add(child); names.push(child); } }); } } return nameSet; } function isEventWithShiftKey( event: React.SyntheticEvent ): event is React.SyntheticEvent & { shiftKey: boolean } { return "shiftKey" in event; } function getTreeModel( parentMap: Map, childrenMap: Map>, selection: string[], disabledSet: Set, shiftSelect: CheckTreeProps["shiftSelect"], prevCheck: string, setPrevCheck: (name: string) => void ) { // selected leaf const selectionSet = new Set(selection); // find node state via checkName const nodeStateMap = new Map< string, { value: boolean; indeterminate: boolean } >(); // classify check by level let levelMap: string[][] = [[]]; const visited: CheckProps[] = []; function visit(check: CheckProps) { if (!visited.find(({ name }) => name === check.name)) { visited.push(check); } } function calcLevel() { const roots = visited .filter(check => !parentMap.has(check.name)) .map(c => c.name); if (roots.length) { let curLevel = 0; levelMap = [roots]; const nameSet = new Set(roots); while (true) { levelMap[curLevel + 1] = []; /* eslint-disable no-loop-func */ levelMap[curLevel].forEach(checkName => { if (childrenMap.has(checkName)) { const children = childrenMap.get(checkName); visited.forEach(({ name }) => { if (children.has(name) && !nameSet.has(name)) { nameSet.add(name); levelMap[curLevel + 1].push(name); } }); } }); /* eslint-enable no-loop-func */ curLevel += 1; if (!levelMap[curLevel].length) { break; } } } } function getVisitedIndex(checkName: string) { let index; let level; levelMap.forEach((checks, _level) => { checks.forEach((name, _index) => { if (name === checkName) { level = _level; index = _index; } }); }); return [level, index]; } function getVisitedName(level: number, index: number) { if (!levelMap[level]) { return undefined; } return levelMap[level][index]; } function getNodeState(checkName: string) { if (nodeStateMap.has(checkName)) { return nodeStateMap.get(checkName); } const children = childrenMap.get(checkName); // 叶子节点直接看是否在选取里 if (!children) { const nodeState = { value: selectionSet.has(checkName), indeterminate: false, }; nodeStateMap.set(checkName, nodeState); return nodeState; } let hasTouchedChild = false; let isAllChildrenChecked = !disabledSet.has(checkName); for (const child of children) { if (child === checkName) { console.error( `Warning: Each node should have a unique "value". (value: "${checkName}")` ); // eslint-disable-next-line no-continue continue; } const childState = getNodeState(child); if (childState.value || childState.indeterminate) { hasTouchedChild = true; } /** except disabled node */ if (!childState.value && !disabledSet.has(child)) { isAllChildrenChecked = false; } } const nodeState = { value: isAllChildrenChecked, indeterminate: !isAllChildrenChecked && hasTouchedChild, }; nodeStateMap.set(checkName, nodeState); return nodeState; } function setNodeState(checkName: string, checked: boolean) { // disabled if (disabledSet.has(checkName)) { return; } const children = childrenMap.get(checkName); // 叶子节点,直接设置 if (!children) { if (checked) { selectionSet.add(checkName); } else { selectionSet.delete(checkName); } } // 递归设置 else { for (const child of children) { setNodeState(child, checked); } } } function getSelection() { return Array.from(selectionSet); } function handleChange( checkName: string, checked: boolean, event: React.SyntheticEvent ): [string[], Pick] { // 多选 if (shiftSelect) { if (event && isEventWithShiftKey(event) && event.shiftKey) { calcLevel(); const [level, index] = getVisitedIndex(checkName); if (prevCheck !== null) { const [prevLevel, prevIndex] = getVisitedIndex(prevCheck); if (level === prevLevel) { // 清空文字选区 if ("getSelection" in window) { window.getSelection().removeAllRanges(); } const min = index < prevIndex ? index : prevIndex; const max = index > prevIndex ? index : prevIndex; // 虚拟滚动依赖 startName/endName 在外部处理选中 if (shiftSelect !== "virtualized") { for (let i = min; i <= max; ++i) { setNodeState(getVisitedName(level, i), true); } } setPrevCheck(checkName); return [ getSelection(), { shiftSelect: { startName: getVisitedName(level, min), endName: getVisitedName(level, max), }, }, ]; } } } if (checked) { setPrevCheck(checkName); } else { setPrevCheck(null); } } setNodeState(checkName, checked); return [getSelection(), {}]; } return { visit, getNodeState, setNodeState, getSelection, handleChange, }; }