@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering
717 lines (624 loc) • 19.2 kB
text/typescript
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()
}
dispose() {
this.remove()
CssLoader.clean(this.name)
}
}