feat: 大纲树支持节点过滤

This commit is contained in:
久戈 2022-04-26 16:27:02 +08:00 committed by LeoYuan 袁力皓
parent 670eeb9fe2
commit f30db20606
11 changed files with 351 additions and 9 deletions

View File

@ -1,12 +1,48 @@
import { Component, isValidElement } from 'react'; import { Component, isValidElement, ReactNode } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { createIcon } from '@alilc/lowcode-utils'; import { createIcon } from '@alilc/lowcode-utils';
import { TitleContent, isI18nData } from '@alilc/lowcode-types'; import { TitleContent, isI18nData, I18nData } from '@alilc/lowcode-types';
import { intl } from '../../intl'; import { intl } from '../../intl';
import { Tip } from '../tip'; import { Tip } from '../tip';
import './title.less'; import './title.less';
export class Title extends Component<{ title: TitleContent; className?: string; onClick?: () => void }> { /**
* keywords label
* title = '自定义页面布局'keywords = '页面' ['自定义', '页面', '布局']
* @param label title
* @param keywords
* @returns
*/
function splitLabelByKeywords(label: string, keywords: string): string[] {
const len = keywords.length;
const fragments = [];
let str = label;
while (str.length > 0) {
const index = str.indexOf(keywords);
if (index === 0) {
fragments.push(keywords);
str = str.slice(len);
} else if (index < 0) {
fragments.push(str);
str = '';
} else {
fragments.push(str.slice(0, index));
str = str.slice(index);
}
}
return fragments;
}
export class Title extends Component<{
title: TitleContent;
className?: string;
onClick?: () => void;
match?: boolean;
keywords?: string;
}> {
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.handleClick = this.handleClick.bind(this); this.handleClick = this.handleClick.bind(this);
@ -24,6 +60,32 @@ export class Title extends Component<{ title: TitleContent; className?: string;
onClick && onClick(e); onClick && onClick(e);
} }
renderLabel = (label: string | I18nData | ReactNode) => {
let { match, keywords } = this.props;
if (!label) {
return null;
}
const intlLabel = intl(label);
if (typeof intlLabel !== 'string') {
return <span className="lc-title-txt">{intlLabel}</span>;
}
let labelToRender: ReactNode = intlLabel;
if (match && keywords) {
const fragments = splitLabelByKeywords(intlLabel as string, keywords);
labelToRender = fragments.map(f => <span style={{ color: f === keywords ? 'red' : 'inherit' }}>{f}</span>);
}
return (
<span className="lc-title-txt">{labelToRender}</span>
);
};
render() { render() {
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let { title, className } = this.props; let { title, className } = this.props;
@ -61,7 +123,7 @@ export class Title extends Component<{ title: TitleContent; className?: string;
onClick={this.handleClick} onClick={this.handleClick}
> >
{icon ? <b className="lc-title-icon">{icon}</b> : null} {icon ? <b className="lc-title-icon">{icon}</b> : null}
{title.label ? <span className="lc-title-txt">{intl(title.label)}</span> : null} {this.renderLabel(title.label)}
{tip} {tip}
</span> </span>
); );

View File

@ -117,7 +117,7 @@ body {
} }
*/ */
} }
.lc-outline-pane { .lc-outline-tree-container {
border-top: 1px solid var(--color-line-normal, rgba(31, 56, 88, 0.1)); border-top: 1px solid var(--color-line-normal, rgba(31, 56, 88, 0.1));
} }
} }

View File

@ -0,0 +1,11 @@
import { SVGIcon, IconProps } from '@alilc/lowcode-utils';
export function IconFilter(props: IconProps) {
return (
<SVGIcon viewBox="0 0 1024 1024" {...props}>
<path d="M911.457097 168.557714a35.986286 35.986286 0 0 1-8.009143 40.009143L621.73824 490.276571V914.285714c0 14.848-9.142857 28.013714-22.272 33.718857A42.349714 42.349714 0 0 1 585.166811 950.857143a34.084571 34.084571 0 0 1-25.709714-10.861714l-146.285714-146.285715A36.425143 36.425143 0 0 1 402.309669 768v-277.723429L120.599954 208.566857a35.986286 35.986286 0 0 1-8.009143-40.009143C118.295954 155.428571 131.461669 146.285714 146.309669 146.285714h731.428571c14.848 0 28.013714 9.142857 33.718857 22.272z" fill="#666" p-id="2025" />
</SVGIcon>
);
}
IconFilter.displayName = 'IconFilter';

View File

@ -3,6 +3,20 @@ import { computed, obx, intl, makeObservable, action } from '@alilc/lowcode-edit
import { Node, DocumentModel, isLocationChildrenDetail, LocationChildrenDetail, Designer } from '@alilc/lowcode-designer'; import { Node, DocumentModel, isLocationChildrenDetail, LocationChildrenDetail, Designer } from '@alilc/lowcode-designer';
import { Tree } from './tree'; import { Tree } from './tree';
/**
*
*/
export interface FilterResult {
// 过滤条件是否生效
filterWorking: boolean;
// 命中子节点
matchChild: boolean;
// 命中本节点
matchSelf: boolean;
// 关键字
keywords: string;
}
export default class TreeNode { export default class TreeNode {
get id(): string { get id(): string {
return this.node.id; return this.node.id;
@ -231,4 +245,20 @@ export default class TreeNode {
this._node = node; this._node = node;
} }
} }
@obx.ref private _filterResult: FilterResult = {
filterWorking: false,
matchChild: false,
matchSelf: false,
keywords: '',
};
get filterReult(): FilterResult {
return this._filterResult;
}
@action
setFilterReult(val: FilterResult) {
this._filterResult = val;
}
} }

View File

@ -0,0 +1,88 @@
import TreeNode from '../tree-node';
export const FilterType = {
CONDITION: 'CONDITION',
LOOP: 'LOOP',
LOCKED: 'LOCKED',
HIDDEN: 'HIDDEN',
};
export const FILTER_OPTIONS = [{
value: FilterType.CONDITION,
label: '条件渲染',
}, {
value: FilterType.LOOP,
label: '循环渲染',
}, {
value: FilterType.LOCKED,
label: '已锁定',
}, {
value: FilterType.HIDDEN,
label: '已隐藏',
}];
export const matchTreeNode = (
treeNode: TreeNode,
keywords: string,
filterOps: string[],
): boolean => {
// 无效节点
if (!treeNode || !treeNode.node) {
return false;
}
// 过滤条件为空,重置过滤结果
if (!keywords && filterOps.length === 0) {
treeNode.setFilterReult({
filterWorking: false,
matchChild: false,
matchSelf: false,
keywords: '',
});
(treeNode.children || []).concat(treeNode.slots || []).forEach((childNode) => {
matchTreeNode(childNode, keywords, filterOps);
});
return false;
}
const { node } = treeNode;
// 命中过滤选项
const matchFilterOps = filterOps.length === 0 || !!filterOps.find((op: string) => {
switch (op) {
case FilterType.CONDITION:
return node.hasCondition();
case FilterType.LOOP:
return node.hasLoop();
case FilterType.LOCKED:
return treeNode.locked;
case FilterType.HIDDEN:
return treeNode.hidden;
default:
return false;
}
});
// 命中节点名
const matchKeywords = typeof treeNode.titleLabel === 'string' && treeNode.titleLabel.indexOf(keywords) > -1;
// 同时命中才展示(根结点永远命中)
const matchSelf = treeNode.isRoot() || (matchFilterOps && matchKeywords);
// 命中子节点
const matchChild = !!(treeNode.children || []).concat(treeNode.slots || [])
.map((childNode: TreeNode) => {
return matchTreeNode(childNode, keywords, filterOps);
}).find(Boolean);
treeNode.setFilterReult({
filterWorking: true,
matchChild,
matchSelf,
keywords,
});
return matchSelf || matchChild;
};

View File

@ -0,0 +1,100 @@
import React, { Component } from 'react';
import './style.less';
import { IconFilter } from '../icons/filter';
import { Search, Checkbox, Balloon, Divider } from '@alifd/next';
import TreeNode from '../tree-node';
import { Tree } from '../tree';
import { matchTreeNode, FILTER_OPTIONS } from './filter-tree';
interface IState {
keywords: string;
filterOps: string[];
}
interface IProps {
tree: Tree;
}
export default class Filter extends Component<IProps, IState> {
state = {
keywords: '',
filterOps: [],
};
handleSearchChange = (val: string) => {
this.setState({
keywords: val.trim(),
}, this.filterTree);
};
handleOptionChange = (val: string[]) => {
this.setState({
filterOps: val,
}, this.filterTree);
};
handleCheckAll = () => {
const { filterOps } = this.state;
const final = filterOps.length === FILTER_OPTIONS.length
? [] : FILTER_OPTIONS.map((op) => op.value);
this.handleOptionChange(final);
};
filterTree() {
const { tree } = this.props;
const { keywords, filterOps } = this.state;
matchTreeNode(tree.root as TreeNode, keywords, filterOps);
}
render() {
const { keywords, filterOps } = this.state;
const indeterminate = filterOps.length > 0 && filterOps.length < FILTER_OPTIONS.length;
const checkAll = filterOps.length === FILTER_OPTIONS.length;
return (
<div className="lc-outline-filter">
<Search
hasClear
shape="simple"
placeholder="过滤节点"
className="lc-outline-filter-search-input"
value={keywords}
onChange={this.handleSearchChange}
/>
<Balloon
v2
align="br"
closable={false}
triggerType="hover"
trigger={(
<div className="lc-outline-filter-icon">
<IconFilter />
</div>
)}
>
<Checkbox
checked={checkAll}
indeterminate={indeterminate}
onChange={this.handleCheckAll}
>
</Checkbox>
<Divider />
<Checkbox.Group
value={filterOps}
direction="ver"
onChange={this.handleOptionChange}
>
{FILTER_OPTIONS.map((op) => (
<Checkbox id={op.value} value={op.value}>
{op.label}
</Checkbox>
))}
</Checkbox.Group>
</Balloon>
</div>
);
}
}

View File

@ -5,6 +5,7 @@ import { OutlineMain } from '../main';
import TreeView from './tree'; import TreeView from './tree';
import './style.less'; import './style.less';
import { IEditor } from '@alilc/lowcode-types'; import { IEditor } from '@alilc/lowcode-types';
import Filter from './filter';
@observer @observer
export class OutlinePane extends Component<{ config: any; editor: IEditor }> { export class OutlinePane extends Component<{ config: any; editor: IEditor }> {
@ -27,6 +28,7 @@ export class OutlinePane extends Component<{ config: any; editor: IEditor }> {
return ( return (
<div className="lc-outline-pane"> <div className="lc-outline-pane">
<Filter tree={tree} />
<div ref={(shell) => this.main.mount(shell)} className="lc-outline-tree-container"> <div ref={(shell) => this.main.mount(shell)} className="lc-outline-tree-container">
<TreeView key={tree.id} tree={tree} /> <TreeView key={tree.id} tree={tree} />
</div> </div>

View File

@ -6,13 +6,41 @@
background-color: white; background-color: white;
> .lc-outline-tree-container { > .lc-outline-tree-container {
top: 0; top: 52px;
left: 0; left: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
position: absolute; position: absolute;
overflow: auto; overflow: auto;
} }
> .lc-outline-filter {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: right;
.lc-outline-filter-search-input {
width: 100%;
}
.lc-outline-filter-icon {
background: #ebecf0;
border: 1px solid #c4c6cf;
height: 28px;
display: flex;
align-items: center;
border-radius: 0 2px 2px 0;
overflow: hidden;
margin-left: -2px;
z-index: 1;
padding: 0 6px;
&:hover {
cursor: pointer;
}
}
}
} }
.lc-outline-tree { .lc-outline-tree {

View File

@ -14,8 +14,11 @@ export default class TreeBranches extends Component<{
render() { render() {
const { treeNode, isModal } = this.props; const { treeNode, isModal } = this.props;
const { expanded } = treeNode; const { expanded } = treeNode;
const { filterWorking, matchChild } = treeNode.filterReult;
// 条件过滤生效时,如果命中了子节点,需要将该节点展开
const expandInFilterResult = filterWorking && matchChild;
if (!expanded) { if (!expandInFilterResult && !expanded) {
return null; return null;
} }
@ -40,12 +43,18 @@ class TreeNodeChildren extends Component<{
const children: any = []; const children: any = [];
let groupContents: any[] = []; let groupContents: any[] = [];
let currentGrp: ExclusiveGroup; let currentGrp: ExclusiveGroup;
const { filterWorking, matchSelf, keywords } = treeNode.filterReult;
const endGroup = () => { const endGroup = () => {
if (groupContents.length > 0) { if (groupContents.length > 0) {
children.push( children.push(
<div key={currentGrp.id} className="condition-group-container" data-id={currentGrp.firstNode.id}> <div key={currentGrp.id} className="condition-group-container" data-id={currentGrp.firstNode.id}>
<div className="condition-group-title"> <div className="condition-group-title">
<Title title={currentGrp.title} /> <Title
title={currentGrp.title}
match={filterWorking && matchSelf}
keywords={keywords}
/>
</div> </div>
{groupContents} {groupContents}
</div>, </div>,

View File

@ -32,6 +32,13 @@ export default class TreeNodeView extends Component<{
highlight: treeNode.isFocusingNode(), highlight: treeNode.isFocusingNode(),
}); });
const { filterWorking, matchChild, matchSelf } = treeNode.filterReult;
// 条件过滤生效时,如果未命中本节点或子节点,则不展示该节点
if (filterWorking && !matchChild && !matchSelf) {
return null;
}
return ( return (
<div className={className} data-id={treeNode.id}> <div className={className} data-id={treeNode.id}>
<TreeTitle treeNode={treeNode} isModal={isModal} /> <TreeTitle treeNode={treeNode} isModal={isModal} />

View File

@ -96,6 +96,7 @@ export default class TreeTitle extends Component<{
marginLeft: -indent, marginLeft: -indent,
}; };
} }
const { filterWorking, matchSelf, keywords } = treeNode.filterReult;
return ( return (
<div <div
@ -143,7 +144,11 @@ export default class TreeTitle extends Component<{
/> />
) : ( ) : (
<Fragment> <Fragment>
<Title title={treeNode.title} /> <Title
title={treeNode.title}
match={filterWorking && matchSelf}
keywords={keywords}
/>
{node.slotFor && ( {node.slotFor && (
<a className="tree-node-tag slot"> <a className="tree-node-tag slot">
{/* todo: click redirect to prop */} {/* todo: click redirect to prop */}