diff --git a/packages/editor-core/src/widgets/title/index.tsx b/packages/editor-core/src/widgets/title/index.tsx index 79fd11881..bacfee9f1 100644 --- a/packages/editor-core/src/widgets/title/index.tsx +++ b/packages/editor-core/src/widgets/title/index.tsx @@ -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 {intlLabel}; + } + + let labelToRender: ReactNode = intlLabel; + + if (match && keywords) { + const fragments = splitLabelByKeywords(intlLabel as string, keywords); + + labelToRender = fragments.map(f => {f}); + } + + return ( + {labelToRender} + ); + }; + 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 ? {icon} : null} - {title.label ? {intl(title.label)} : null} + {this.renderLabel(title.label)} {tip} ); diff --git a/packages/editor-skeleton/src/layouts/workbench.less b/packages/editor-skeleton/src/layouts/workbench.less index 1004f6327..02ff36966 100644 --- a/packages/editor-skeleton/src/layouts/workbench.less +++ b/packages/editor-skeleton/src/layouts/workbench.less @@ -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)); } } diff --git a/packages/plugin-outline-pane/src/icons/filter.tsx b/packages/plugin-outline-pane/src/icons/filter.tsx new file mode 100644 index 000000000..11e2f719d --- /dev/null +++ b/packages/plugin-outline-pane/src/icons/filter.tsx @@ -0,0 +1,11 @@ +import { SVGIcon, IconProps } from '@alilc/lowcode-utils'; + +export function IconFilter(props: IconProps) { + return ( + + + + ); +} + +IconFilter.displayName = 'IconFilter'; diff --git a/packages/plugin-outline-pane/src/tree-node.ts b/packages/plugin-outline-pane/src/tree-node.ts index 5408ce95b..67efe5fba 100644 --- a/packages/plugin-outline-pane/src/tree-node.ts +++ b/packages/plugin-outline-pane/src/tree-node.ts @@ -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; + } } diff --git a/packages/plugin-outline-pane/src/views/filter-tree.ts b/packages/plugin-outline-pane/src/views/filter-tree.ts new file mode 100644 index 000000000..2f38ada57 --- /dev/null +++ b/packages/plugin-outline-pane/src/views/filter-tree.ts @@ -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; +}; diff --git a/packages/plugin-outline-pane/src/views/filter.tsx b/packages/plugin-outline-pane/src/views/filter.tsx new file mode 100644 index 000000000..18153dd24 --- /dev/null +++ b/packages/plugin-outline-pane/src/views/filter.tsx @@ -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 { + 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 ( +
+ + + +
+ )} + > + + 全选 + + + + {FILTER_OPTIONS.map((op) => ( + + {op.label} + + ))} + + + + ); + } +} diff --git a/packages/plugin-outline-pane/src/views/pane.tsx b/packages/plugin-outline-pane/src/views/pane.tsx index 136bb41bd..a57bfad92 100644 --- a/packages/plugin-outline-pane/src/views/pane.tsx +++ b/packages/plugin-outline-pane/src/views/pane.tsx @@ -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 (
+
this.main.mount(shell)} className="lc-outline-tree-container">
diff --git a/packages/plugin-outline-pane/src/views/style.less b/packages/plugin-outline-pane/src/views/style.less index 32fd979cf..423dbbb4e 100644 --- a/packages/plugin-outline-pane/src/views/style.less +++ b/packages/plugin-outline-pane/src/views/style.less @@ -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 { diff --git a/packages/plugin-outline-pane/src/views/tree-branches.tsx b/packages/plugin-outline-pane/src/views/tree-branches.tsx index 6a953a29b..4521fe2e7 100644 --- a/packages/plugin-outline-pane/src/views/tree-branches.tsx +++ b/packages/plugin-outline-pane/src/views/tree-branches.tsx @@ -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(
- + <Title + title={currentGrp.title} + match={filterWorking && matchSelf} + keywords={keywords} + /> </div> {groupContents} </div>, diff --git a/packages/plugin-outline-pane/src/views/tree-node.tsx b/packages/plugin-outline-pane/src/views/tree-node.tsx index 234296ce5..282be0a21 100644 --- a/packages/plugin-outline-pane/src/views/tree-node.tsx +++ b/packages/plugin-outline-pane/src/views/tree-node.tsx @@ -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} /> diff --git a/packages/plugin-outline-pane/src/views/tree-title.tsx b/packages/plugin-outline-pane/src/views/tree-title.tsx index 42a75d804..245c10934 100644 --- a/packages/plugin-outline-pane/src/views/tree-title.tsx +++ b/packages/plugin-outline-pane/src/views/tree-title.tsx @@ -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 */}