@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering.
539 lines (464 loc) • 15.1 kB
text/typescript
import { FunctionExt } from '../../util'
import { grid } from '../../layout/grid'
import { Cell } from '../../model/cell'
import { Node } from '../../model/node'
import { Model } from '../../model/model'
import { View } from '../../view/view'
import { Graph } from '../../graph/graph'
import { EventArgs } from '../../graph/events'
import { Dnd } from '../dnd'
export class Stencil extends View {
public readonly options: Stencil.Options
public readonly dnd: Dnd
protected readonly graphs: { [groupName: string]: Graph }
protected readonly $groups: { [groupName: string]: JQuery<HTMLElement> }
protected readonly $container: JQuery<HTMLDivElement>
protected readonly $content: JQuery<HTMLDivElement>
protected get targetScroller() {
const target = this.options.target
return Graph.isGraph(target) ? target.scroller.widget : target
}
protected get targetGraph() {
const target = this.options.target
return Graph.isGraph(target) ? target : target.graph
}
protected get targetModel() {
return this.targetGraph.model
}
constructor(options: Partial<Stencil.Options>) {
super()
this.graphs = {}
this.$groups = {}
this.options = {
...Stencil.defaultOptions,
...options,
} as Stencil.Options
this.dnd = new Dnd(this.options)
this.onSearch = FunctionExt.debounce(this.onSearch, 200)
this.container = document.createElement('div')
this.$container = this.$(this.container)
.addClass(this.prefixClassName(ClassNames.base))
.attr(
'data-not-found-text',
this.options.notFoundText || 'No matches found',
)
this.options.collapsable =
options.collapsable &&
options.groups &&
options.groups.some((group) => group.collapsable !== false)
if (this.options.collapsable) {
this.$container.addClass('collapsable')
const collapsed =
options.groups &&
options.groups.every(
(group) => group.collapsed || group.collapsable === false,
)
if (collapsed) {
this.$container.addClass('collapsed')
}
}
this.$('<div/>')
.addClass(this.prefixClassName(ClassNames.title))
.html(this.options.title)
.appendTo(this.$container)
if (options.search) {
this.$container.addClass('searchable').append(this.renderSearch())
}
this.$content = this.$('<div/>')
.addClass(this.prefixClassName(ClassNames.content))
.appendTo(this.$container)
const globalGraphOptions = options.stencilGraphOptions || {}
if (options.groups && options.groups.length) {
options.groups.forEach((group) => {
const $group = this.$('<div/>')
.addClass(this.prefixClassName(ClassNames.group))
.attr('data-name', group.name)
if (
(group.collapsable == null && options.collapsable) ||
group.collapsable !== false
) {
$group.addClass('collapsable')
}
$group.toggleClass('collapsed', group.collapsed === true)
const $title = this.$('<h3/>')
.addClass(this.prefixClassName(ClassNames.groupTitle))
.html(group.title || group.name)
const $content = this.$('<div/>').addClass(
this.prefixClassName(ClassNames.groupContent),
)
const graphOptionsInGroup = group.graphOptions
const graph = new Graph({
...globalGraphOptions,
...graphOptionsInGroup,
container: document.createElement('div'),
model: globalGraphOptions.model || new Model(),
width: group.graphWidth || options.stencilGraphWidth,
height: group.graphHeight || options.stencilGraphHeight,
interacting: false,
preventDefaultBlankAction: false,
})
$content.append(graph.container)
$group.append($title, $content).appendTo(this.$content)
this.$groups[group.name] = $group
this.graphs[group.name] = graph
})
} else {
const graph = new Graph({
...globalGraphOptions,
container: document.createElement('div'),
model: globalGraphOptions.model || new Model(),
width: options.stencilGraphWidth,
height: options.stencilGraphHeight,
interacting: false,
preventDefaultBlankAction: false,
})
this.$content.append(graph.container)
this.graphs[Private.defaultGroupName] = graph
}
this.startListening()
return this
}
protected renderSearch() {
return this.$('<div/>')
.addClass(this.prefixClassName(ClassNames.search))
.append(
this.$('<input/>')
.attr({
type: 'search',
placeholder: this.options.placeholder || 'Search',
})
.addClass(this.prefixClassName(ClassNames.searchText)),
)
}
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',
})
Object.keys(this.graphs).forEach((groupName) => {
const graph = this.graphs[groupName]
graph.on('cell:mousedown', this.onDragStart, this)
})
}
protected stopListening() {
this.undelegateEvents()
Object.keys(this.graphs).forEach((groupName) => {
const graph = this.graphs[groupName]
graph.off('cell:mousedown', this.onDragStart, this)
})
}
load(groups: { [groupName: string]: (Node | Node.Metadata)[] }): this
load(nodes: (Node | Node.Metadata)[], groupName?: string): this
load(
data:
| { [groupName: string]: (Node | Node.Metadata)[] }
| (Node | Node.Metadata)[],
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
}
protected loadGroup(cells: (Node | Node.Metadata)[], groupName?: string) {
const model = this.getModel(groupName)
if (model) {
const nodes = cells.map((cell) =>
Node.isNode(cell) ? cell : Node.create(cell),
)
model.resetCells(nodes)
}
const group = this.getGroup(groupName)
let height = this.options.stencilGraphHeight
if (group && group.graphHeight != null) {
height = group.graphHeight
}
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
this.dnd.start(node, e)
}
protected filter(keyword: string, filter?: Stencil.Filter) {
const found = Object.keys(this.graphs).reduce((memo, groupName) => {
const graph = this.graphs[groupName]
const name = groupName === Private.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) {
view.$(view.container).toggleClass('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]) {
this.$groups[groupName].toggleClass('unmatched', !found)
}
graph.fitToContent({
gridWidth: 1,
gridHeight: 1,
padding: options.stencilGraphPadding || 10,
})
return memo || found
}, false)
this.$container.toggleClass('not-found', !found)
}
protected isCellMatched(
cell: Cell,
keyword: string,
filters: Stencil.Filters | 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: JQuery.TriggeredEvent) {
this.filter(evt.target.value as string, this.options.search)
}
protected onSearchFocusIn() {
this.$container.addClass('is-focused')
}
protected onSearchFocusOut() {
this.$container.removeClass('is-focused')
}
protected onTitleClick() {
if (this.options.collapsable) {
this.$container.toggleClass('collapsed')
if (this.$container.hasClass('collapsed')) {
this.collapseGroups()
} else {
this.expandGroups()
}
}
}
protected onGroupTitleClick(evt: JQuery.TriggeredEvent) {
const $group = this.$(evt.target).closest(
`.${this.prefixClassName(ClassNames.group)}`,
)
this.toggleGroup($group.attr('data-name') || '')
const allCollapsed = Object.keys(this.$groups).every((name) => {
const group = this.getGroup(name)
const $group = this.$groups[name]
return (
(group && group.collapsable === false) || $group.hasClass('collapsed')
)
})
this.$container.toggleClass('collapsed', allCollapsed)
}
protected getModel(groupName?: string) {
const graph = this.getGraph(groupName)
return graph ? graph.model : null
}
protected getGraph(groupName?: string) {
return this.graphs[groupName || Private.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
}
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 })
$group.addClass('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 })
$group.removeClass('collapsed')
}
}
return this
}
isGroupCollapsable(groupName: string) {
const $group = this.$groups[groupName]
return $group.hasClass('collapsable')
}
isGroupCollapsed(groupName: string) {
const $group = this.$groups[groupName]
return $group && $group.hasClass('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
}
onRemove() {
Object.keys(this.graphs).forEach((groupName) => {
const graph = this.graphs[groupName]
graph.view.remove()
delete this.graphs[groupName]
})
this.dnd.remove()
this.stopListening()
this.undelegateDocumentEvents()
}
}
export namespace Stencil {
export interface Options extends Dnd.Options {
title: string
groups?: Group[]
search?: Filter
placeholder?: string
notFoundText?: string
collapsable?: boolean
stencilGraphWidth: number
stencilGraphHeight: number
stencilGraphOptions?: Graph.Options
stencilGraphPadding?: number
layout?: (this: Stencil, model: Model, group?: Group | null) => any
layoutOptions?: any
}
export type Filter = Filters | FilterFn | boolean
export type Filters = { [shape: string]: string | string[] | boolean }
export type FilterFn = (
this: Stencil,
cell: Node,
keyword: string,
groupName: string | null,
stencil: Stencil,
) => boolean
export interface Group {
name: string
title?: string
collapsed?: boolean
collapsable?: boolean
graphWidth?: number
graphHeight?: number
graphPadding?: number
graphOptions?: Graph.Options
layout?: (this: Stencil, model: Model, group?: Group | null) => any
layoutOptions?: any
}
export const defaultOptions: Partial<Options> = {
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 : {}),
})
},
...Dnd.defaults,
}
}
namespace ClassNames {
export const base = 'widget-stencil'
export const title = `${base}-title`
export const search = `${base}-search`
export const searchText = `${search}-text`
export const content = `${base}-content`
export const group = `${base}-group`
export const groupTitle = `${group}-title`
export const groupContent = `${group}-content`
}
namespace Private {
export const defaultGroupName = '__default__'
}