UNPKG

@antv/x6

Version:

JavaScript diagramming library that uses SVG and HTML for rendering

717 lines (624 loc) 19.2 kB
import { CssLoader, Dom, disposable, FunctionExt } from '../../common' import { type EventArgs, Graph, type Options as GraphOptions, type GraphPlugin, } from '../../graph' import { type Cell, Model, Node, type NodeMetadata } from '../../model' import { View } from '../../view' import { Dnd, DndDefaults } from '../dnd' import type { Scroller } from '../scroller' import { grid } from './grid' import { content } from './style/raw' import type { StencilFilter, StencilFilters, StencilGroup, StencilOptions, } from './type' export const ClassNames = { base: 'widget-stencil', title: `widget-stencil-title`, search: `widget-stencil-search`, searchText: `widget-stencil-search-text`, content: `widget-stencil-content`, group: `widget-stencil-group`, groupTitle: `widget-stencil-group-title`, groupContent: `widget-stencil-group-content`, } export const DefaultGroupName = '__default__' export const DefaultOptions: Partial<StencilOptions> = { stencilGraphWidth: 200, stencilGraphHeight: 800, title: 'Stencil', collapsable: false, placeholder: 'Search', notFoundText: 'No matches found', layout(model, group) { const options = { columnWidth: (this.options.stencilGraphWidth as number) / 2 - 10, columns: 2, rowHeight: 80, resizeToFit: false, dx: 10, dy: 10, } grid(model, { ...options, ...this.options.layoutOptions, ...(group ? group.layoutOptions : {}), }) }, ...DndDefaults, } export class Stencil extends View implements GraphPlugin { public name = 'stencil' public options: StencilOptions public dnd: Dnd protected graphs: { [groupName: string]: Graph } protected groups: { [groupName: string]: HTMLElement } protected content: HTMLDivElement protected get targetScroller() { const target = this.options.target const scroller = target.getPlugin<Scroller>('scroller') return scroller } protected get targetGraph() { return this.options.target } protected get targetModel() { return this.targetGraph.model } constructor(options: Partial<StencilOptions> = {}) { super() CssLoader.ensure(this.name, content) this.graphs = {} this.groups = {} this.options = { ...DefaultOptions, ...options, } as StencilOptions this.init() } init() { this.dnd = new Dnd(this.options) this.onSearch = FunctionExt.debounce(this.onSearch, 200) this.initContainer() this.initSearch() this.initContent() this.initGroups() this.setTitle() this.startListening() } // #region api load(groups: { [groupName: string]: (Node | NodeMetadata)[] }): this load(nodes: (Node | NodeMetadata)[], groupName?: string): this load( data: | { [groupName: string]: (Node | NodeMetadata)[] } | (Node | NodeMetadata)[], groupName?: string, ) { if (Array.isArray(data)) { this.loadGroup(data, groupName) } else if (this.options.groups) { Object.keys(this.options.groups).forEach((groupName) => { if (data[groupName]) { this.loadGroup(data[groupName], groupName) } }) } return this } unload(groups: { [groupName: string]: (Node | NodeMetadata)[] }): this unload(nodes: (Node | NodeMetadata)[], groupName?: string): this unload( data: | { [groupName: string]: (Node | NodeMetadata)[] } | (Node | NodeMetadata)[], groupName?: string, ) { if (Array.isArray(data)) { this.loadGroup(data, groupName, true) } else if (this.options.groups) { Object.keys(this.options.groups).forEach((groupName) => { if (data[groupName]) { this.loadGroup(data[groupName], groupName, true) } }) } return this } toggleGroup(groupName: string) { if (this.isGroupCollapsed(groupName)) { this.expandGroup(groupName) } else { this.collapseGroup(groupName) } return this } collapseGroup(groupName: string) { if (this.isGroupCollapsable(groupName)) { const group = this.groups[groupName] if (group && !this.isGroupCollapsed(groupName)) { this.trigger('group:collapse', { name: groupName }) Dom.addClass(group, 'collapsed') } } return this } expandGroup(groupName: string) { if (this.isGroupCollapsable(groupName)) { const group = this.groups[groupName] if (group && this.isGroupCollapsed(groupName)) { this.trigger('group:expand', { name: groupName }) Dom.removeClass(group, 'collapsed') } } return this } isGroupCollapsable(groupName: string) { const group = this.groups[groupName] return Dom.hasClass(group, 'collapsable') } isGroupCollapsed(groupName: string) { const group = this.groups[groupName] return group && Dom.hasClass(group, 'collapsed') } collapseGroups() { Object.keys(this.groups).forEach((groupName) => { this.collapseGroup(groupName) }) return this } expandGroups() { Object.keys(this.groups).forEach((groupName) => { this.expandGroup(groupName) }) return this } resizeGroup(groupName: string, size: { width: number; height: number }) { const graph = this.graphs[groupName] if (graph) { graph.resize(size.width, size.height) } return this } addGroup(group: StencilGroup | StencilGroup[]) { const groups = Array.isArray(group) ? group : [group] if (this.options.groups) { this.options.groups.push(...groups) } else { this.options.groups = groups } groups.forEach((group) => { this.initGroup(group) }) } removeGroup(groupName: string | string[]) { const groupNames = Array.isArray(groupName) ? groupName : [groupName] if (this.options.groups) { this.options.groups = this.options.groups.filter( (group) => !groupNames.includes(group.name), ) groupNames.forEach((groupName) => { const graph = this.graphs[groupName] this.unregisterGraphEvents(graph) graph.dispose() delete this.graphs[groupName] const elem = this.groups[groupName] Dom.remove(elem) delete this.groups[groupName] }) } } // #endregion protected initContainer() { this.container = document.createElement('div') Dom.addClass(this.container, this.prefixClassName(ClassNames.base)) Dom.attr( this.container, 'data-not-found-text', this.options.notFoundText || 'No matches found', ) } protected initContent() { this.content = document.createElement('div') Dom.addClass(this.content, this.prefixClassName(ClassNames.content)) Dom.appendTo(this.content, this.container) } protected buildGraphConfig(group?: StencilGroup) { const globalGraphOptions = this.options.stencilGraphOptions || {} const graphOptionsInGroup = group?.graphOptions const mergedGraphOptions = { ...globalGraphOptions, ...graphOptionsInGroup, } if (mergedGraphOptions.panning == null) { mergedGraphOptions.panning = false } const width = (group && group.graphWidth) || this.options.stencilGraphWidth const height = (group && group.graphHeight) || this.options.stencilGraphHeight const model = mergedGraphOptions.model || new Model() return { mergedGraphOptions, width, height, model } } protected createStencilGraph( mergedGraphOptions: Partial<GraphOptions>, width: number, height: number, model: Model, ) { const graph = new Graph({ ...mergedGraphOptions, container: document.createElement('div'), model, width, height, interacting: false, preventDefaultBlankAction: false, }) this.registerGraphEvents(graph) return graph } protected initSearch() { if (this.options.search) { Dom.addClass(this.container, 'searchable') Dom.append(this.container, this.renderSearch()) } } protected initGroup(group: StencilGroup) { const groupElem = document.createElement('div') Dom.addClass(groupElem, this.prefixClassName(ClassNames.group)) Dom.attr(groupElem, 'data-name', group.name) if ( (group.collapsable == null && this.options.collapsable) || group.collapsable !== false ) { Dom.addClass(groupElem, 'collapsable') } Dom.toggleClass(groupElem, 'collapsed', group.collapsed === true) const title = document.createElement('h3') Dom.addClass(title, this.prefixClassName(ClassNames.groupTitle)) title.innerHTML = group.title || group.name const content = document.createElement('div') Dom.addClass(content, this.prefixClassName(ClassNames.groupContent)) const { mergedGraphOptions, width, height, model } = this.buildGraphConfig(group) const graph = this.createStencilGraph( mergedGraphOptions, width as number, height as number, model, ) Dom.append(content, graph.container) Dom.append(groupElem, [title, content]) Dom.appendTo(groupElem, this.content) this.groups[group.name] = groupElem this.graphs[group.name] = graph } protected initGroups() { this.clearGroups() this.setCollapsableState() if (this.options.groups && this.options.groups.length) { this.options.groups.forEach((group) => { this.initGroup(group) }) } else { const { mergedGraphOptions, width, height, model } = this.buildGraphConfig() const graph = this.createStencilGraph( mergedGraphOptions, width as number, height as number, model, ) Dom.append(this.content, graph.container) this.graphs[DefaultGroupName] = graph } } protected setCollapsableState() { this.options.collapsable = this.options.collapsable && this.options.groups && this.options.groups.some((group) => group.collapsable !== false) if (this.options.collapsable) { Dom.addClass(this.container, 'collapsable') const collapsed = this.options.groups && this.options.groups.every( (group) => group.collapsed || group.collapsable === false, ) if (collapsed) { Dom.addClass(this.container, 'collapsed') } else { Dom.removeClass(this.container, 'collapsed') } } else { Dom.removeClass(this.container, 'collapsable') } } protected setTitle() { const title = document.createElement('div') Dom.addClass(title, this.prefixClassName(ClassNames.title)) title.innerHTML = this.options.title Dom.appendTo(title, this.container) } protected renderSearch() { const elem = document.createElement('div') Dom.addClass(elem, this.prefixClassName(ClassNames.search)) const input = document.createElement('input') Dom.attr(input, { type: 'search', placeholder: this.options.placeholder || 'Search', }) Dom.addClass(input, this.prefixClassName(ClassNames.searchText)) Dom.append(elem, input) return elem } protected startListening() { const title = this.prefixClassName(ClassNames.title) const searchText = this.prefixClassName(ClassNames.searchText) const groupTitle = this.prefixClassName(ClassNames.groupTitle) this.delegateEvents({ [`click .${title}`]: 'onTitleClick', [`touchstart .${title}`]: 'onTitleClick', [`click .${groupTitle}`]: 'onGroupTitleClick', [`touchstart .${groupTitle}`]: 'onGroupTitleClick', [`input .${searchText}`]: 'onSearch', [`focusin .${searchText}`]: 'onSearchFocusIn', [`focusout .${searchText}`]: 'onSearchFocusOut', }) } protected stopListening() { this.undelegateEvents() } protected registerGraphEvents(graph: Graph) { graph.on('cell:mousedown', this.onDragStart, this) } protected unregisterGraphEvents(graph: Graph) { graph.off('cell:mousedown', this.onDragStart, this) } protected getGraphHeight(groupName?: string) { const group = this.getGroup(groupName) if (group && group.graphHeight != null) { return group.graphHeight } return this.options.stencilGraphHeight } protected loadGroup( cells: (Node | NodeMetadata)[], groupName?: string, reverse?: boolean, ) { const model = this.getModel(groupName) if (model) { const nodes = cells.map((cell) => Node.isNode(cell) ? cell : Node.create(cell), ) if (reverse === true) { model.removeCells(nodes) } else { model.resetCells(nodes) } } const group = this.getGroup(groupName) const height = this.getGraphHeight(groupName) const layout = (group && group.layout) || this.options.layout if (layout && model) { FunctionExt.call(layout, this, model, group) } if (!height) { const graph = this.getGraph(groupName) graph.fitToContent({ minWidth: graph.options.width, gridHeight: 1, padding: (group && group.graphPadding) || this.options.stencilGraphPadding || 10, }) } return this } protected onDragStart(args: EventArgs['node:mousedown']) { const { e, node } = args const group = this.getGroupByNode(node) if (group && group.nodeMovable === false) { return } // 当在 Stencil 中拖拽节点时,禁用该分组 Graph 的平移(panning) const graph = this.getGraph(group ? group.name : undefined) const wasPannable = graph && typeof graph.isPannable === 'function' ? graph.isPannable() : false if (wasPannable) { graph.disablePanning() } // 在拖拽结束(document mouseup/touchend)后恢复之前的 panning 状态。 const restorePanning = () => { if (wasPannable) { graph.enablePanning() } this.undelegateDocumentEvents() } this.delegateDocumentEvents({ mouseup: restorePanning, touchend: restorePanning, touchcancel: restorePanning, }) this.dnd.start(node, e) } protected filter(keyword: string, filter?: StencilFilter) { const found = Object.keys(this.graphs).reduce((memo, groupName) => { const graph = this.graphs[groupName] const name = groupName === DefaultGroupName ? null : groupName const items = graph.model.getNodes().filter((cell) => { let matched = false if (typeof filter === 'function') { matched = FunctionExt.call(filter, this, cell, keyword, name, this) } else if (typeof filter === 'boolean') { matched = filter } else { matched = this.isCellMatched( cell, keyword, filter, keyword.toLowerCase() !== keyword, ) } const view = graph.renderer.findViewByCell(cell) if (view) { Dom.toggleClass(view.container, 'unmatched', !matched) } return matched }) const found = items.length > 0 const options = this.options const model = new Model() model.resetCells(items) if (options.layout) { FunctionExt.call(options.layout, this, model, this.getGroup(groupName)) } if (this.groups[groupName]) { Dom.toggleClass(this.groups[groupName], 'unmatched', !found) } const height = this.getGraphHeight(groupName) if (!height) { graph.fitToContent({ gridWidth: 1, gridHeight: 1, padding: options.stencilGraphPadding || 10, contentArea: model.getAllCellsBBox() || { x: 0, y: 0, width: 0, height: 0, }, }) } return memo || found }, false) Dom.toggleClass(this.container, 'not-found', !found) } protected isCellMatched( cell: Cell, keyword: string, filters: StencilFilters | undefined, ignoreCase: boolean, ) { if (keyword && filters) { return Object.keys(filters).some((shape) => { if (shape === '*' || cell.shape === shape) { const filter = filters[shape] if (typeof filter === 'boolean') { return filter } const paths = Array.isArray(filter) ? filter : [filter] return paths.some((path) => { let val = cell.getPropByPath<string>(path) if (val != null) { val = `${val}` if (!ignoreCase) { val = val.toLowerCase() } return val.indexOf(keyword) >= 0 } return false }) } return false }) } return true } protected onSearch(evt: Dom.EventObject) { this.filter(evt.target.value as string, this.options.search) } protected onSearchFocusIn() { Dom.addClass(this.container, 'is-focused') } protected onSearchFocusOut() { Dom.removeClass(this.container, 'is-focused') } protected onTitleClick() { if (this.options.collapsable) { Dom.toggleClass(this.container, 'collapsed') if (Dom.hasClass(this.container, 'collapsed')) { this.collapseGroups() } else { this.expandGroups() } } } protected onGroupTitleClick(evt: Dom.EventObject) { const group = evt.target.closest( `.${this.prefixClassName(ClassNames.group)}`, ) if (group) { this.toggleGroup(Dom.attr(group, 'data-name') || '') } const allCollapsed = Object.keys(this.groups).every((name) => { const group = this.getGroup(name) const groupElem = this.groups[name] return ( (group && group.collapsable === false) || Dom.hasClass(groupElem, 'collapsed') ) }) Dom.toggleClass(this.container, 'collapsed', allCollapsed) } protected getModel(groupName?: string) { const graph = this.getGraph(groupName) return graph ? graph.model : null } protected getGraph(groupName?: string) { return this.graphs[groupName || DefaultGroupName] } protected getGroup(groupName?: string) { const groups = this.options.groups if (groupName != null && groups && groups.length) { return groups.find((group) => group.name === groupName) } return null } protected getGroupByNode(node: Node) { const groups = this.options.groups if (groups) { return groups.find((group) => { const model = this.getModel(group.name) if (model) { return model.has(node.id) } return false }) } return null } protected clearGroups() { Object.keys(this.graphs).forEach((groupName) => { const graph = this.graphs[groupName] this.unregisterGraphEvents(graph) graph.dispose() }) Object.keys(this.groups).forEach((groupName) => { const elem = this.groups[groupName] Dom.remove(elem) }) this.graphs = {} this.groups = {} } protected onRemove() { this.clearGroups() this.dnd.remove() this.stopListening() this.undelegateDocumentEvents() } @disposable() dispose() { this.remove() CssLoader.clean(this.name) } }