Match-id-afbd9aba70098fe6a0446b81b44bcd1220e61ee5

This commit is contained in:
* 2022-04-07 19:15:31 +08:00 committed by *
parent cc11ffd17f
commit b3a46f31c5
4 changed files with 210 additions and 100 deletions

View File

@ -1,77 +1,151 @@
// 过滤树的抽象逻辑实现 // 过滤树的抽象逻辑
// 需要知道渲染了哪些数据,搜索的字符串 // 需要知道渲染了哪些数据,过滤的字符串/正则表达式
// 控制Tree组件位置跳转告知搜索文本 // 控制Tree组件位置跳转告知匹配结果
// 清空搜索框,告知搜索框当前是第几个结果,跳转搜索结果接口 // 清空搜索框,告知搜索框当前是第几个结果,跳转搜索结果
//
// 跳转搜索结果的交互逻辑:
// 如果当前页面存在匹配项,页面不动
// 如果当前页面不存在匹配项,页面跳转到第一个匹配项位置
// 如果匹配项被折叠,需要展开其父节点。注意只展开当前匹配项的父节点,其他匹配项的父节点不展开
// 跳转到上一个匹配项或下一个匹配项时,如果匹配项被折叠,需要展开其父节点
//
// 寻找父节点:
// 找到该节点的缩进值和index值在data中向上遍历通过缩进值判断父节点
import { useState, useRef } from 'horizon'; import { useState, useRef } from 'horizon';
import { createRegExp } from '../utils'; import { createRegExp } from '../utils';
export function FilterTree<T extends { /**
*
*
* @param item
* @param data
* @param collapsedNodes
* @returns
*/
function expandItemParent(item: BaseType, data: BaseType[], collapsedNodes: BaseType[]): BaseType[] {
const index = data.indexOf(item);
let currentIndentation = item.indentation;
// 不对原始数据进行修改
const newcollapsedNodes = [...collapsedNodes];
for (let i = index - 1; i >= 0; i--) {
const lastData = data[i];
const lastIndentation = lastData.indentation;
// 缩进更小,找到了父节点
if (lastIndentation < currentIndentation) {
// 更新缩进值,只找父节点的父节点,避免修改父节点的兄弟节点的展开状态
currentIndentation = lastIndentation;
const cIndex = newcollapsedNodes.indexOf(lastData);
if (cIndex !== -1) {
newcollapsedNodes.splice(cIndex, 1);
}
}
}
return newcollapsedNodes;
}
type BaseType = {
id: string, id: string,
name: string name: string,
}>(props: { data: T[] }) { indentation: number,
}
export function FilterTree<T extends BaseType>(props: { data: T[] }) {
const { data } = props; const { data } = props;
const [filterValue, setFilterValue] = useState(''); const [filterValue, setFilterValue] = useState('');
const [selectId, setSelectId] = useState(null); const [currentItem, setCurrentItem] = useState(null); // 当前选中的匹配项
const showItems = useRef([]); const showItemsRef = useRef([]); // 页面展示的 items
const matchItemsRef = useRef([]); const matchItemsRef = useRef([]); // 匹配过滤条件的 items
const collapsedNodesRef = useRef([]); // 折叠节点,如果匹配 item 被折叠了,需要展开
const matchItems = matchItemsRef.current; const matchItems = matchItemsRef.current;
const collapsedNodes = collapsedNodesRef.current;
const updatecollapsedNodes = (item: BaseType) => {
const newcollapsedNodes = expandItemParent(item, data, collapsedNodes);
// 如果新旧收起节点数组长度不一样,说明存在收起节点
if (newcollapsedNodes.length !== collapsedNodes.length) {
// 更新引用,确保 VTree 拿到新的 collapsedNodes
collapsedNodesRef.current = newcollapsedNodes;
}
};
const onChangeSearchValue = (search: string) => { const onChangeSearchValue = (search: string) => {
const reg = createRegExp(search); const reg = createRegExp(search);
let matchShowId = null; let newCurrentItem = null;
let newMatchItems = []; let newMatchItems = [];
if (search !== '') { if (search !== '') {
const showItems: T[] = showItemsRef.current;
newMatchItems = data.reduce((pre, current) => { newMatchItems = data.reduce((pre, current) => {
const { id, name } = current; const { name } = current;
if (reg && name.match(reg)) { if (reg && name.match(reg)) {
pre.push(id); pre.push(current);
if (matchShowId === null) { // 如果当前页面显示的 item 存在匹配项,则把它设置为 currentItem
matchShowId = id; if (newCurrentItem === null && showItems.includes(current)) {
newCurrentItem = current;
} }
} }
return pre; return pre;
}, []); }, []);
if (newMatchItems.length === 0) { if (newMatchItems.length === 0) {
setSelectId(null); setCurrentItem(null);
} else { } else {
if (matchShowId === null) { if (newCurrentItem === null) {
setSelectId(newMatchItems[0]); const item = newMatchItems[0];
// 不处于当前展示页面,需要展开父节点
updatecollapsedNodes(item);
setCurrentItem(item);
} else { } else {
setSelectId(matchShowId); setCurrentItem(newCurrentItem);
} }
} }
} else {
setCurrentItem(null);
} }
matchItemsRef.current = newMatchItems; matchItemsRef.current = newMatchItems;
setFilterValue(search); setFilterValue(search);
}; };
const onSelectNext = () => { const onSelectNext = () => {
const index = matchItems.indexOf(selectId); const index = matchItems.indexOf(currentItem);
const nextIndex = index + 1; const nextIndex = index + 1;
if (nextIndex < matchItemsRef.current.length) { if (nextIndex < matchItemsRef.current.length) {
setSelectId(matchItems[nextIndex]); const item = matchItems[nextIndex];
// 不处于当前展示页面,需要展开父节点
updatecollapsedNodes(item);
setCurrentItem(item);
} }
}; };
const onSelectLast = () => { const onSelectLast = () => {
const index = matchItems.indexOf(selectId); const index = matchItems.indexOf(currentItem);
const last = index - 1; const last = index - 1;
if (last >= 0) { if (last >= 0) {
setSelectId(matchItems[last]); const item = matchItems[last];
// 不处于当前展示页面,需要展开父节点
updatecollapsedNodes(item);
setCurrentItem(matchItems[last]);
} }
}; };
const setShowItems = (items) => { const setShowItems = (items) => {
showItems.current = [...items]; showItemsRef.current = [...items];
}; };
const onClear = () => { const onClear = () => {
onChangeSearchValue(''); onChangeSearchValue('');
}; };
const setcollapsedNodes = (items) => {
// 不更新引用,避免子组件的重复渲染
collapsedNodesRef.current.length = 0;
collapsedNodesRef.current.push(...items);
};
return { return {
filterValue, filterValue,
setFilterValue: onChangeSearchValue, onChangeSearchValue,
onClear, onClear,
selectId, currentItem,
matchItems, matchItems,
onSelectNext, onSelectNext,
onSelectLast, onSelectLast,
setShowItems, setShowItems,
collapsedNodes,
setcollapsedNodes,
}; };
} }

View File

@ -8,33 +8,48 @@ interface IProps<T extends { id: string }> {
height: number, // VList 的高度 height: number, // VList 的高度
children: any, // horizon 组件,组件类型是 T children: any, // horizon 组件,组件类型是 T
itemHeight: number, itemHeight: number,
scrollIndex?: number, scrollToItem?: T, // 滚动到指定项位置,如果该项在可见区域内,不滚动,如果不在,则滚动到中间位置
onRendered:(renderInfo: renderInfoType) => void; onRendered: (renderInfo: renderInfoType<T>) => void;
filter?(data: T): boolean, // false 表示该行不显示 filter?(data: T): boolean, // false 表示该行不显示
} }
const defaultRenderInfo = { export type renderInfoType<T> = {
visibleItems: ([] as string[]) visibleItems: T[],
skipItemCountBeforeScrollItem: number,
}; };
export type renderInfoType = typeof defaultRenderInfo;
export function VList<T extends { id: string }>(props: IProps<T>) { export function VList<T extends { id: string }>(props: IProps<T>) {
const { const {
data, data,
height, height,
children, children,
itemHeight, itemHeight,
scrollIndex = 0, scrollToItem,
filter, filter,
onRendered, onRendered,
} = props; } = props;
const [scrollTop, setScrollTop] = useState(scrollIndex * itemHeight); const [scrollTop, setScrollTop] = useState(data.indexOf(scrollToItem) * itemHeight);
const renderInfo = useRef({visibleItems: []}); const renderInfoRef: { current: renderInfoType<T> } = useRef({ visibleItems: [], skipItemCountBeforeScrollItem: 0 });
const containerRef = useRef();
useEffect(() => { useEffect(() => {
onRendered(renderInfo.current); onRendered(renderInfoRef.current);
}); });
useEffect(() => {
if (scrollToItem) {
const renderInfo = renderInfoRef.current;
// 在滚动区域,不滚动
if (!renderInfo.visibleItems.includes(scrollToItem)) {
const index = data.indexOf(scrollToItem);
// top值计算需要减掉filter条件判定不显示项
const totalCount = index - renderInfoRef.current.skipItemCountBeforeScrollItem;
// 显示在页面中间
const top = totalCount * itemHeight - height / 2;
containerRef.current.scrollTo({ top: top });
}
}
}, [scrollToItem]);
const handleScroll = (event: any) => { const handleScroll = (event: any) => {
const scrollTop = event.target.scrollTop; const scrollTop = event.target.scrollTop;
setScrollTop(scrollTop); setScrollTop(scrollTop);
@ -48,9 +63,14 @@ export function VList<T extends { id: string }>(props: IProps<T>) {
// 如果最后一个显示不全,不统计在显示 ids 内 // 如果最后一个显示不全,不统计在显示 ids 内
const maxTop = scrollTop + height - itemHeight; const maxTop = scrollTop + height - itemHeight;
// 清空记录的上次渲染的数据 // 清空记录的上次渲染的数据
renderInfo.current.visibleItems.length = 0; renderInfoRef.current.visibleItems.length = 0;
const scrollItemIndex = data.indexOf(scrollToItem);
renderInfoRef.current.skipItemCountBeforeScrollItem = 0;
data.forEach((item, i) => { data.forEach((item, i) => {
if (filter && !filter(item)) { if (filter && !filter(item)) {
if (scrollItemIndex > i) {
renderInfoRef.current.skipItemCountBeforeScrollItem++;
}
return; return;
} }
if (totalHeight >= startShowTopValue && showList.length <= showNum) { if (totalHeight >= startShowTopValue && showList.length <= showNum) {
@ -63,14 +83,14 @@ export function VList<T extends { id: string }>(props: IProps<T>) {
</div> </div>
); );
if (totalHeight >= scrollTop && totalHeight < maxTop) { if (totalHeight >= scrollTop && totalHeight < maxTop) {
renderInfo.current.visibleItems.push(item); renderInfoRef.current.visibleItems.push(item);
} }
} }
totalHeight += itemHeight; totalHeight += itemHeight;
}); });
return ( return (
<div className={styles.container} onScroll={handleScroll}> <div ref={containerRef} className={styles.container} onScroll={handleScroll}>
{showList} {showList}
<div style={{ marginTop: totalHeight }} /> <div style={{ marginTop: totalHeight }} />
</div> </div>

View File

@ -14,8 +14,8 @@ export interface IData {
interface IItem { interface IItem {
hasChild: boolean, hasChild: boolean,
onCollapse: (id: string) => void, onCollapse: (data: IData) => void,
onClick: (id: string) => void, onClick: (id: IData) => void,
isCollapsed: boolean, isCollapsed: boolean,
isSelect: boolean, isSelect: boolean,
highlightValue: string, highlightValue: string,
@ -34,21 +34,20 @@ function Item(props: IItem) {
isSelect, isSelect,
highlightValue = '', highlightValue = '',
} = props; } = props;
const { const {
name, name,
userKey, userKey,
id, indentation,
indentation,
} = data; } = data;
const isShowKey = userKey !== ''; const isShowKey = userKey !== '';
const showIcon = hasChild ? <Triangle director={isCollapsed ? 'right' : 'down'} /> : ''; const showIcon = hasChild ? <Triangle director={isCollapsed ? 'right' : 'down'} /> : '';
const handleClickCollapse = () => { const handleClickCollapse = () => {
onCollapse(id); onCollapse(data);
}; };
const handleClick = () => { const handleClick = () => {
onClick(id); onClick(data);
}; };
const itemAttr: any = { className: styles.treeItem, onClick: handleClick }; const itemAttr: any = { className: styles.treeItem, onClick: handleClick };
if (isSelect) { if (isSelect) {
@ -96,31 +95,39 @@ function Item(props: IItem) {
); );
} }
function VTree({ data, highlightValue, selectedId, onRendered }: { function VTree(props: {
data: IData[], data: IData[],
highlightValue: string, highlightValue: string,
selectedId: number, scrollToItem: IData,
onRendered: (renderInfo: renderInfoType) => void onRendered: (renderInfo: renderInfoType<IData>) => void,
collapsedNodes?: IData[],
onCollapseNode?: (item: IData[]) => void,
}) { }) {
const [collapseNode, setCollapseNode] = useState(new Set<string>()); const { data, highlightValue, scrollToItem, onRendered, onCollapseNode } = props;
const [selectItem, setSelectItem] = useState(selectedId); const [collapseNode, setCollapseNode] = useState(props.collapsedNodes || []);
const [selectItem, setSelectItem] = useState(scrollToItem);
useEffect(() => { useEffect(() => {
setSelectItem(selectedId); setSelectItem(scrollToItem);
}, [selectedId]); }, [scrollToItem]);
const changeCollapseNode = (id: string) => { useEffect(() => {
const nodes = new Set<string>(); setCollapseNode(props.collapsedNodes || []);
collapseNode.forEach(value => { }, [props.collapsedNodes]);
nodes.add(value);
}); const changeCollapseNode = (item: IData) => {
if (nodes.has(id)) { const nodes: IData[] = [...collapseNode];
nodes.delete(id); const index = nodes.indexOf(item);
if (index === -1) {
nodes.push(item);
} else { } else {
nodes.add(id); nodes.splice(index, 1);
} }
setCollapseNode(nodes); setCollapseNode(nodes);
if (onCollapseNode) {
onCollapseNode(nodes);
}
}; };
const handleClickItem = (id: string) => { const handleClickItem = (item: IData) => {
setSelectItem(id); setSelectItem(item);
}; };
let currentCollapseIndentation: null | number = null; let currentCollapseIndentation: null | number = null;
@ -135,8 +142,7 @@ function VTree({ data, highlightValue, selectedId, onRendered }: {
currentCollapseIndentation = null; currentCollapseIndentation = null;
} }
} }
const id = item.id; const isCollapsed = collapseNode.includes(item);
const isCollapsed = collapseNode.has(id);
if (isCollapsed) { if (isCollapsed) {
// 该节点需要收起子节点 // 该节点需要收起子节点
currentCollapseIndentation = item.indentation; currentCollapseIndentation = item.indentation;
@ -146,14 +152,14 @@ function VTree({ data, highlightValue, selectedId, onRendered }: {
return ( return (
<SizeObserver className={styles.treeContainer}> <SizeObserver className={styles.treeContainer}>
{(width, height) => { {(width: number, height: number) => {
return ( return (
<VList <VList
data={data} data={data}
width={width} width={width}
height={height} height={height}
itemHeight={18} itemHeight={18}
scrollIndex={data.indexOf(selectItem)} scrollToItem={scrollToItem}
filter={filter} filter={filter}
onRendered={onRendered} onRendered={onRendered}
> >
@ -164,8 +170,8 @@ function VTree({ data, highlightValue, selectedId, onRendered }: {
return ( return (
<Item <Item
hasChild={hasChild} hasChild={hasChild}
isCollapsed={collapseNode.has(item.id)} isCollapsed={collapseNode.includes(item)}
isSelect={selectItem === item.id} isSelect={selectItem === item}
onCollapse={changeCollapseNode} onCollapse={changeCollapseNode}
onClick={handleClickItem} onClick={handleClickItem}
highlightValue={highlightValue} highlightValue={highlightValue}

View File

@ -9,13 +9,39 @@ import { FilterTree } from '../components/FilterTree';
import Close from '../svgs/Close'; import Close from '../svgs/Close';
import Arrow from './../svgs/Arrow'; import Arrow from './../svgs/Arrow';
const parseVNodeData = (rawData) => {
const idIndentationMap: {
[id: string]: number;
} = {};
const data: IData[] = [];
let i = 0;
while (i < rawData.length) {
const id = rawData[i] as string;
i++;
const name = rawData[i] as string;
i++;
const parentId = rawData[i] as string;
i++;
const userKey = rawData[i] as string;
i++;
const indentation = parentId === '' ? 0 : idIndentationMap[parentId] + 1;
idIndentationMap[id] = indentation;
const item = {
id, name, indentation, userKey
};
data.push(item);
}
return data;
};
function App() { function App() {
const [parsedVNodeData, setParsedVNodeData] = useState([]); const [parsedVNodeData, setParsedVNodeData] = useState([]);
const [componentInfo, setComponentInfo] = useState({ name: null, attrs: {} }); const [componentInfo, setComponentInfo] = useState({ name: null, attrs: {} });
useEffect(() => { useEffect(() => {
if (isDev) { if (isDev) {
setParsedVNodeData(mockParsedVNodeData); const parsedData = parseVNodeData(mockParsedVNodeData);
setParsedVNodeData(parsedData);
setComponentInfo({ setComponentInfo({
name: 'Demo', name: 'Demo',
attrs: { attrs: {
@ -25,37 +51,19 @@ function App() {
}); });
} }
}, []); }, []);
const idIndentationMap: {
[id: string]: number;
} = {};
const data: IData[] = [];
let i = 0;
while (i < parsedVNodeData.length) {
const id = parsedVNodeData[i] as string;
i++;
const name = parsedVNodeData[i] as string;
i++;
const parentId = parsedVNodeData[i] as string;
i++;
const userKey = parsedVNodeData[i] as string;
i++;
const indentation = parentId === '' ? 0 : idIndentationMap[parentId] + 1;
idIndentationMap[id] = indentation;
const item = {
id, name, indentation, userKey
};
data.push(item);
}
const { const {
filterValue, filterValue,
setFilterValue, onChangeSearchValue: setFilterValue,
onClear, onClear,
selectId, currentItem,
matchItems, matchItems,
onSelectNext, onSelectNext,
onSelectLast, onSelectLast,
setShowItems, setShowItems,
} = FilterTree({ data }); collapsedNodes,
setcollapsedNodes,
} = FilterTree({ data: parsedVNodeData });
const handleSearchChange = (str: string) => { const handleSearchChange = (str: string) => {
setFilterValue(str); setFilterValue(str);
@ -77,19 +85,21 @@ function App() {
<Search onChange={handleSearchChange} value={filterValue} /> <Search onChange={handleSearchChange} value={filterValue} />
</div> </div>
{filterValue !== '' && <> {filterValue !== '' && <>
<span className={styles.searchResult}>{`${matchItems.indexOf(selectId) + 1}/${matchItems.length}`}</span> <span className={styles.searchResult}>{`${matchItems.indexOf(currentItem) + 1}/${matchItems.length}`}</span>
<div className={styles.divider} /> <div className={styles.divider} />
<button className={styles.searchAction} onClick={onSelectLast}><Arrow direction={'up'}/></button> <button className={styles.searchAction} onClick={onSelectLast}><Arrow direction={'up'} /></button>
<button className={styles.searchAction} onClick={onSelectNext}><Arrow direction={'down'}/></button> <button className={styles.searchAction} onClick={onSelectNext}><Arrow direction={'down'} /></button>
<button className={styles.searchAction} onClick={onClear}><Close/></button> <button className={styles.searchAction} onClick={onClear}><Close /></button>
</>} </>}
</div> </div>
<div className={styles.left_bottom}> <div className={styles.left_bottom}>
<VTree <VTree
data={data} data={parsedVNodeData}
highlightValue={filterValue} highlightValue={filterValue}
onRendered={onRendered} onRendered={onRendered}
selectedId={selectId} /> collapsedNodes={collapsedNodes}
onCollapseNode={setcollapsedNodes}
scrollToItem={currentItem} />
</div> </div>
</div> </div>
<div className={styles.right}> <div className={styles.right}>