mirror of
https://github.com/alibaba/lowcode-engine.git
synced 2025-12-12 11:20:11 +00:00
feat: 大纲树支持节点过滤
This commit is contained in:
parent
670eeb9fe2
commit
f30db20606
@ -1,12 +1,48 @@
|
||||
import { Component, isValidElement } from 'react';
|
||||
import { Component, isValidElement, ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
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 { Tip } from '../tip';
|
||||
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) {
|
||||
super(props);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
@ -24,6 +60,32 @@ export class Title extends Component<{ title: TitleContent; className?: string;
|
||||
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() {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { title, className } = this.props;
|
||||
@ -61,7 +123,7 @@ export class Title extends Component<{ title: TitleContent; className?: string;
|
||||
onClick={this.handleClick}
|
||||
>
|
||||
{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}
|
||||
</span>
|
||||
);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
11
packages/plugin-outline-pane/src/icons/filter.tsx
Normal file
11
packages/plugin-outline-pane/src/icons/filter.tsx
Normal 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';
|
||||
@ -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 { Tree } from './tree';
|
||||
|
||||
/**
|
||||
* 大纲树过滤结果
|
||||
*/
|
||||
export interface FilterResult {
|
||||
// 过滤条件是否生效
|
||||
filterWorking: boolean;
|
||||
// 命中子节点
|
||||
matchChild: boolean;
|
||||
// 命中本节点
|
||||
matchSelf: boolean;
|
||||
// 关键字
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
export default class TreeNode {
|
||||
get id(): string {
|
||||
return this.node.id;
|
||||
@ -231,4 +245,20 @@ export default class TreeNode {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
88
packages/plugin-outline-pane/src/views/filter-tree.ts
Normal file
88
packages/plugin-outline-pane/src/views/filter-tree.ts
Normal 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;
|
||||
};
|
||||
100
packages/plugin-outline-pane/src/views/filter.tsx
Normal file
100
packages/plugin-outline-pane/src/views/filter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { OutlineMain } from '../main';
|
||||
import TreeView from './tree';
|
||||
import './style.less';
|
||||
import { IEditor } from '@alilc/lowcode-types';
|
||||
import Filter from './filter';
|
||||
|
||||
@observer
|
||||
export class OutlinePane extends Component<{ config: any; editor: IEditor }> {
|
||||
@ -27,6 +28,7 @@ export class OutlinePane extends Component<{ config: any; editor: IEditor }> {
|
||||
|
||||
return (
|
||||
<div className="lc-outline-pane">
|
||||
<Filter tree={tree} />
|
||||
<div ref={(shell) => this.main.mount(shell)} className="lc-outline-tree-container">
|
||||
<TreeView key={tree.id} tree={tree} />
|
||||
</div>
|
||||
|
||||
@ -6,13 +6,41 @@
|
||||
background-color: white;
|
||||
|
||||
> .lc-outline-tree-container {
|
||||
top: 0;
|
||||
top: 52px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
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 {
|
||||
|
||||
@ -14,8 +14,11 @@ export default class TreeBranches extends Component<{
|
||||
render() {
|
||||
const { treeNode, isModal } = this.props;
|
||||
const { expanded } = treeNode;
|
||||
const { filterWorking, matchChild } = treeNode.filterReult;
|
||||
// 条件过滤生效时,如果命中了子节点,需要将该节点展开
|
||||
const expandInFilterResult = filterWorking && matchChild;
|
||||
|
||||
if (!expanded) {
|
||||
if (!expandInFilterResult && !expanded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -40,12 +43,18 @@ class TreeNodeChildren extends Component<{
|
||||
const children: any = [];
|
||||
let groupContents: any[] = [];
|
||||
let currentGrp: ExclusiveGroup;
|
||||
const { filterWorking, matchSelf, keywords } = treeNode.filterReult;
|
||||
|
||||
const endGroup = () => {
|
||||
if (groupContents.length > 0) {
|
||||
children.push(
|
||||
<div key={currentGrp.id} className="condition-group-container" data-id={currentGrp.firstNode.id}>
|
||||
<div className="condition-group-title">
|
||||
<Title title={currentGrp.title} />
|
||||
<Title
|
||||
title={currentGrp.title}
|
||||
match={filterWorking && matchSelf}
|
||||
keywords={keywords}
|
||||
/>
|
||||
</div>
|
||||
{groupContents}
|
||||
</div>,
|
||||
|
||||
@ -32,6 +32,13 @@ export default class TreeNodeView extends Component<{
|
||||
highlight: treeNode.isFocusingNode(),
|
||||
});
|
||||
|
||||
const { filterWorking, matchChild, matchSelf } = treeNode.filterReult;
|
||||
|
||||
// 条件过滤生效时,如果未命中本节点或子节点,则不展示该节点
|
||||
if (filterWorking && !matchChild && !matchSelf) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} data-id={treeNode.id}>
|
||||
<TreeTitle treeNode={treeNode} isModal={isModal} />
|
||||
|
||||
@ -96,6 +96,7 @@ export default class TreeTitle extends Component<{
|
||||
marginLeft: -indent,
|
||||
};
|
||||
}
|
||||
const { filterWorking, matchSelf, keywords } = treeNode.filterReult;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -143,7 +144,11 @@ export default class TreeTitle extends Component<{
|
||||
/>
|
||||
) : (
|
||||
<Fragment>
|
||||
<Title title={treeNode.title} />
|
||||
<Title
|
||||
title={treeNode.title}
|
||||
match={filterWorking && matchSelf}
|
||||
keywords={keywords}
|
||||
/>
|
||||
{node.slotFor && (
|
||||
<a className="tree-node-tag slot">
|
||||
{/* todo: click redirect to prop */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user