@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering
669 lines (583 loc) • 17.4 kB
text/typescript
import { Dom, FunctionExt } from '@antv/x6-common'
import { Cell } from '../model'
import { Config } from '../config'
import { View, Markup, CellView } from '../view'
import { Graph } from '../graph'
export class GraphView extends View {
public readonly container: HTMLElement
public readonly background: HTMLDivElement
public readonly grid: HTMLDivElement
public readonly svg: SVGSVGElement
public readonly defs: SVGDefsElement
public readonly viewport: SVGGElement
public readonly primer: SVGGElement
public readonly stage: SVGGElement
public readonly decorator: SVGGElement
public readonly overlay: SVGGElement
private restore: () => void
/** Graph's `this.container` is from outer, should not dispose */
protected get disposeContainer(): boolean {
return false
}
protected get options() {
return this.graph.options
}
constructor(protected readonly graph: Graph) {
super()
const { selectors, fragment } = Markup.parseJSONMarkup(GraphView.markup)
this.background = selectors.background as HTMLDivElement
this.grid = selectors.grid as HTMLDivElement
this.svg = selectors.svg as SVGSVGElement
this.defs = selectors.defs as SVGDefsElement
this.viewport = selectors.viewport as SVGGElement
this.primer = selectors.primer as SVGGElement
this.stage = selectors.stage as SVGGElement
this.decorator = selectors.decorator as SVGGElement
this.overlay = selectors.overlay as SVGGElement
this.container = this.options.container
this.restore = GraphView.snapshoot(this.container)
Dom.addClass(this.container, this.prefixClassName('graph'))
Dom.append(this.container, fragment)
this.delegateEvents()
}
delegateEvents() {
const ctor = this.constructor as typeof GraphView
super.delegateEvents(ctor.events)
return this
}
/**
* Guard the specified event. If the event is not interesting, it
* returns `true`, otherwise returns `false`.
*/
guard(e: Dom.EventObject, view?: CellView | null) {
// handled as `contextmenu` type
if (e.type === 'mousedown' && e.button === 2) {
return true
}
if (this.options.guard && this.options.guard(e, view)) {
return true
}
if (e.data && e.data.guarded !== undefined) {
return e.data.guarded
}
if (view && view.cell && Cell.isCell(view.cell)) {
return false
}
if (
this.svg === e.target ||
this.container === e.target ||
this.svg.contains(e.target)
) {
return false
}
return true
}
protected findView(elem: Element) {
return this.graph.findViewByElem(elem)
}
protected onDblClick(evt: Dom.DoubleClickEvent) {
if (this.options.preventDefaultDblClick) {
evt.preventDefault()
}
const e = this.normalizeEvent(evt)
const view = this.findView(e.target)
if (this.guard(e, view)) {
return
}
const localPoint = this.graph.snapToGrid(e.clientX, e.clientY)
if (view) {
view.onDblClick(e, localPoint.x, localPoint.y)
} else {
this.graph.trigger('blank:dblclick', {
e,
x: localPoint.x,
y: localPoint.y,
})
}
}
protected onClick(evt: Dom.ClickEvent) {
if (this.getMouseMovedCount(evt) <= this.options.clickThreshold) {
const e = this.normalizeEvent(evt)
const view = this.findView(e.target)
if (this.guard(e, view)) {
return
}
const localPoint = this.graph.snapToGrid(e.clientX, e.clientY)
if (view) {
view.onClick(e, localPoint.x, localPoint.y)
} else {
this.graph.trigger('blank:click', {
e,
x: localPoint.x,
y: localPoint.y,
})
}
}
}
protected isPreventDefaultContextMenu(view: CellView | null) {
let preventDefaultContextMenu = this.options.preventDefaultContextMenu
if (typeof preventDefaultContextMenu === 'function') {
preventDefaultContextMenu = FunctionExt.call(
preventDefaultContextMenu,
this.graph,
{ view },
)
}
return preventDefaultContextMenu
}
protected onContextMenu(evt: Dom.ContextMenuEvent) {
const e = this.normalizeEvent(evt)
const view = this.findView(e.target)
if (this.isPreventDefaultContextMenu(view)) {
evt.preventDefault()
}
if (this.guard(e, view)) {
return
}
const localPoint = this.graph.snapToGrid(e.clientX, e.clientY)
if (view) {
view.onContextMenu(e, localPoint.x, localPoint.y)
} else {
this.graph.trigger('blank:contextmenu', {
e,
x: localPoint.x,
y: localPoint.y,
})
}
}
delegateDragEvents(e: Dom.MouseDownEvent, view: CellView | null) {
if (e.data == null) {
e.data = {}
}
this.setEventData<EventData.Moving>(e, {
currentView: view || null,
mouseMovedCount: 0,
startPosition: {
x: e.clientX,
y: e.clientY,
},
})
const ctor = this.constructor as typeof GraphView
this.delegateDocumentEvents(ctor.documentEvents, e.data)
this.undelegateEvents()
}
getMouseMovedCount(e: Dom.EventObject) {
const data = this.getEventData<EventData.Moving>(e)
return data.mouseMovedCount || 0
}
protected onMouseDown(evt: Dom.MouseDownEvent) {
const e = this.normalizeEvent(evt)
const view = this.findView(e.target)
if (this.guard(e, view)) {
return
}
if (this.options.preventDefaultMouseDown) {
evt.preventDefault()
}
const localPoint = this.graph.snapToGrid(e.clientX, e.clientY)
if (view) {
view.onMouseDown(e, localPoint.x, localPoint.y)
} else {
if (
this.options.preventDefaultBlankAction &&
['touchstart'].includes(e.type)
) {
evt.preventDefault()
}
this.graph.trigger('blank:mousedown', {
e,
x: localPoint.x,
y: localPoint.y,
})
}
this.delegateDragEvents(e, view)
}
protected onMouseMove(evt: Dom.MouseMoveEvent) {
const data = this.getEventData<EventData.Moving>(evt)
const startPosition = data.startPosition
if (
startPosition &&
startPosition.x === evt.clientX &&
startPosition.y === evt.clientY
) {
return
}
if (data.mouseMovedCount == null) {
data.mouseMovedCount = 0
}
data.mouseMovedCount += 1
const mouseMovedCount = data.mouseMovedCount
if (mouseMovedCount <= this.options.moveThreshold) {
return
}
const e = this.normalizeEvent(evt)
const localPoint = this.graph.snapToGrid(e.clientX, e.clientY)
const view = data.currentView
if (view) {
view.onMouseMove(e, localPoint.x, localPoint.y)
} else {
this.graph.trigger('blank:mousemove', {
e,
x: localPoint.x,
y: localPoint.y,
})
}
this.setEventData(e, data)
}
protected onMouseUp(e: Dom.MouseUpEvent) {
this.undelegateDocumentEvents()
const normalized = this.normalizeEvent(e)
const localPoint = this.graph.snapToGrid(
normalized.clientX,
normalized.clientY,
)
const data = this.getEventData<EventData.Moving>(e)
const view = data.currentView
if (view) {
view.onMouseUp(normalized, localPoint.x, localPoint.y)
} else {
this.graph.trigger('blank:mouseup', {
e: normalized,
x: localPoint.x,
y: localPoint.y,
})
}
if (!e.isPropagationStopped()) {
const ev = new Dom.EventObject(e as any, {
type: 'click',
data: e.data,
}) as Dom.ClickEvent
this.onClick(ev)
}
e.stopImmediatePropagation()
this.delegateEvents()
}
protected onMouseOver(evt: Dom.MouseOverEvent) {
const e = this.normalizeEvent(evt)
const view = this.findView(e.target)
if (this.guard(e, view)) {
return
}
if (view) {
view.onMouseOver(e)
} else {
// prevent border of paper from triggering this
if (this.container === e.target) {
return
}
this.graph.trigger('blank:mouseover', { e })
}
}
protected onMouseOut(evt: Dom.MouseOutEvent) {
const e = this.normalizeEvent(evt)
const view = this.findView(e.target)
if (this.guard(e, view)) {
return
}
if (view) {
view.onMouseOut(e)
} else {
if (this.container === e.target) {
return
}
this.graph.trigger('blank:mouseout', { e })
}
}
protected onMouseEnter(evt: Dom.MouseEnterEvent) {
const e = this.normalizeEvent(evt)
const view = this.findView(e.target)
if (this.guard(e, view)) {
return
}
const relatedView = this.graph.findViewByElem(e.relatedTarget as Element)
if (view) {
if (relatedView === view) {
// mouse moved from tool to view
return
}
view.onMouseEnter(e)
} else {
if (relatedView) {
return
}
this.graph.trigger('graph:mouseenter', { e })
}
}
protected onMouseLeave(evt: Dom.MouseLeaveEvent) {
const e = this.normalizeEvent(evt)
const view = this.findView(e.target)
if (this.guard(e, view)) {
return
}
const relatedView = this.graph.findViewByElem(e.relatedTarget as Element)
if (view) {
if (relatedView === view) {
// mouse moved from view to tool
return
}
view.onMouseLeave(e)
} else {
if (relatedView) {
return
}
this.graph.trigger('graph:mouseleave', { e })
}
}
protected onMouseWheel(evt: Dom.EventObject) {
const e = this.normalizeEvent(evt)
const view = this.findView(e.target)
if (this.guard(e, view)) {
return
}
const originalEvent = e.originalEvent as WheelEvent
const localPoint = this.graph.snapToGrid(
originalEvent.clientX,
originalEvent.clientY,
)
const delta = Math.max(
-1,
Math.min(1, (originalEvent as any).wheelDelta || -originalEvent.detail),
)
if (view) {
view.onMouseWheel(e, localPoint.x, localPoint.y, delta)
} else {
this.graph.trigger('blank:mousewheel', {
e,
delta,
x: localPoint.x,
y: localPoint.y,
})
}
}
protected onCustomEvent(evt: Dom.MouseDownEvent) {
const elem = evt.currentTarget
const event = elem.getAttribute('event') || elem.getAttribute('data-event')
if (event) {
const view = this.findView(elem)
if (view) {
const e = this.normalizeEvent(evt)
if (this.guard(e, view)) {
return
}
const localPoint = this.graph.snapToGrid(
e.clientX as number,
e.clientY as number,
)
view.onCustomEvent(e, event, localPoint.x, localPoint.y)
}
}
}
protected handleMagnetEvent<T extends Dom.EventObject>(
evt: T,
handler: (
this: Graph,
view: CellView,
e: T,
magnet: Element,
x: number,
y: number,
) => void,
) {
const magnetElem = evt.currentTarget
const magnetValue = magnetElem.getAttribute('magnet') as string
if (magnetValue && magnetValue.toLowerCase() !== 'false') {
const view = this.findView(magnetElem)
if (view) {
const e = this.normalizeEvent(evt)
if (this.guard(e, view)) {
return
}
const localPoint = this.graph.snapToGrid(
e.clientX as number,
e.clientY as number,
)
FunctionExt.call(
handler,
this.graph,
view,
e,
magnetElem,
localPoint.x,
localPoint.y,
)
}
}
}
protected onMagnetMouseDown(e: Dom.MouseDownEvent) {
this.handleMagnetEvent(e, (view, e, magnet, x, y) => {
view.onMagnetMouseDown(e, magnet, x, y)
})
}
protected onMagnetDblClick(e: Dom.DoubleClickEvent) {
this.handleMagnetEvent(e, (view, e, magnet, x, y) => {
view.onMagnetDblClick(e, magnet, x, y)
})
}
protected onMagnetContextMenu(e: Dom.ContextMenuEvent) {
const view = this.findView(e.target)
if (this.isPreventDefaultContextMenu(view)) {
e.preventDefault()
}
this.handleMagnetEvent(e, (view, e, magnet, x, y) => {
view.onMagnetContextMenu(e, magnet, x, y)
})
}
protected onLabelMouseDown(evt: Dom.MouseDownEvent) {
const labelNode = evt.currentTarget
const view = this.findView(labelNode)
if (view) {
const e = this.normalizeEvent(evt)
if (this.guard(e, view)) {
return
}
const localPoint = this.graph.snapToGrid(e.clientX, e.clientY)
view.onLabelMouseDown(e, localPoint.x, localPoint.y)
}
}
protected onImageDragStart() {
// This is the only way to prevent image dragging in Firefox that works.
// Setting -moz-user-select: none, draggable="false" attribute or
// user-drag: none didn't help.
return false
}
.dispose()
dispose() {
this.undelegateEvents()
this.undelegateDocumentEvents()
this.restore()
this.restore = () => {}
}
}
export namespace GraphView {
export type SortType = 'none' | 'approx' | 'exact'
}
export namespace GraphView {
const prefixCls = `${Config.prefixCls}-graph`
export const markup: Markup.JSONMarkup[] = [
{
ns: Dom.ns.xhtml,
tagName: 'div',
selector: 'background',
className: `${prefixCls}-background`,
},
{
ns: Dom.ns.xhtml,
tagName: 'div',
selector: 'grid',
className: `${prefixCls}-grid`,
},
{
ns: Dom.ns.svg,
tagName: 'svg',
selector: 'svg',
className: `${prefixCls}-svg`,
attrs: {
width: '100%',
height: '100%',
'xmlns:xlink': Dom.ns.xlink,
},
children: [
{
tagName: 'defs',
selector: 'defs',
},
{
tagName: 'g',
selector: 'viewport',
className: `${prefixCls}-svg-viewport`,
children: [
{
tagName: 'g',
selector: 'primer',
className: `${prefixCls}-svg-primer`,
},
{
tagName: 'g',
selector: 'stage',
className: `${prefixCls}-svg-stage`,
},
{
tagName: 'g',
selector: 'decorator',
className: `${prefixCls}-svg-decorator`,
},
{
tagName: 'g',
selector: 'overlay',
className: `${prefixCls}-svg-overlay`,
},
],
},
],
},
]
export function snapshoot(elem: Element) {
const cloned = elem.cloneNode() as Element
elem.childNodes.forEach((child) => cloned.appendChild(child))
return () => {
// remove all children
Dom.empty(elem)
// remove all attributes
while (elem.attributes.length > 0) {
elem.removeAttribute(elem.attributes[0].name)
}
// restore attributes
for (let i = 0, l = cloned.attributes.length; i < l; i += 1) {
const attr = cloned.attributes[i]
elem.setAttribute(attr.name, attr.value)
}
// restore children
cloned.childNodes.forEach((child) => elem.appendChild(child))
}
}
}
export namespace GraphView {
const prefixCls = Config.prefixCls
export const events = {
dblclick: 'onDblClick',
contextmenu: 'onContextMenu',
touchstart: 'onMouseDown',
mousedown: 'onMouseDown',
mouseover: 'onMouseOver',
mouseout: 'onMouseOut',
mouseenter: 'onMouseEnter',
mouseleave: 'onMouseLeave',
mousewheel: 'onMouseWheel',
DOMMouseScroll: 'onMouseWheel',
[`mouseenter .${prefixCls}-cell`]: 'onMouseEnter',
[`mouseleave .${prefixCls}-cell`]: 'onMouseLeave',
[`mouseenter .${prefixCls}-cell-tools`]: 'onMouseEnter',
[`mouseleave .${prefixCls}-cell-tools`]: 'onMouseLeave',
[`mousedown .${prefixCls}-cell [event]`]: 'onCustomEvent',
[`touchstart .${prefixCls}-cell [event]`]: 'onCustomEvent',
[`mousedown .${prefixCls}-cell [data-event]`]: 'onCustomEvent',
[`touchstart .${prefixCls}-cell [data-event]`]: 'onCustomEvent',
[`dblclick .${prefixCls}-cell [magnet]`]: 'onMagnetDblClick',
[`contextmenu .${prefixCls}-cell [magnet]`]: 'onMagnetContextMenu',
[`mousedown .${prefixCls}-cell [magnet]`]: 'onMagnetMouseDown',
[`touchstart .${prefixCls}-cell [magnet]`]: 'onMagnetMouseDown',
[`dblclick .${prefixCls}-cell [data-magnet]`]: 'onMagnetDblClick',
[`contextmenu .${prefixCls}-cell [data-magnet]`]: 'onMagnetContextMenu',
[`mousedown .${prefixCls}-cell [data-magnet]`]: 'onMagnetMouseDown',
[`touchstart .${prefixCls}-cell [data-magnet]`]: 'onMagnetMouseDown',
[`dragstart .${prefixCls}-cell image`]: 'onImageDragStart',
[`mousedown .${prefixCls}-edge .${prefixCls}-edge-label`]:
'onLabelMouseDown',
[`touchstart .${prefixCls}-edge .${prefixCls}-edge-label`]:
'onLabelMouseDown',
}
export const documentEvents = {
mousemove: 'onMouseMove',
touchmove: 'onMouseMove',
mouseup: 'onMouseUp',
touchend: 'onMouseUp',
touchcancel: 'onMouseUp',
}
}
namespace EventData {
export interface Moving {
mouseMovedCount?: number
startPosition?: { x: number; y: number }
currentView?: CellView | null
}
}