UNPKG

@antv/x6

Version:

JavaScript diagramming library that uses SVG and HTML for rendering.

539 lines (464 loc) 15.1 kB
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__' }