@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering
644 lines (553 loc) • 19.2 kB
text/typescript
import type { NodeViewPositionEventArgs } from '../../view/node/type'
import { Dom, disposable, type KeyValue, NumberExt } from '../../common'
import { DocumentEvents } from '../../constants'
import { Point, snapToGrid } from '../../geometry'
import * as Angle from '../../geometry/angle'
import type { Graph } from '../../graph'
import type { Node, ResizeDirection, ResizeOptions } from '../../model'
import type { PointLike } from '../../types'
import { type NodeView, View } from '../../view'
import type { Scroller } from '../scroller'
interface ResizeEventArgs<E> extends NodeViewPositionEventArgs<E> {}
interface RotateEventArgs<E> extends NodeViewPositionEventArgs<E> {}
export interface TransformImplEventArgs {
'node:resize': ResizeEventArgs<Dom.MouseDownEvent>
'node:resizing': ResizeEventArgs<Dom.MouseMoveEvent>
'node:resized': ResizeEventArgs<Dom.MouseUpEvent>
'node:rotate': RotateEventArgs<Dom.MouseDownEvent>
'node:rotating': RotateEventArgs<Dom.MouseMoveEvent>
'node:rotated': RotateEventArgs<Dom.MouseUpEvent>
}
export interface TransformImplOptions {
className?: string
minWidth?: number
maxWidth?: number
minHeight?: number
maxHeight?: number
resizable?: boolean
rotatable?: boolean
rotateGrid?: number
orthogonalResizing?: boolean
restrictedResizing?: boolean | number
autoScrollOnResizing?: boolean
/**
* Set to `true` if you want the resizing to preserve the
* aspect ratio of the node. Default is `false`.
*/
preserveAspectRatio?: boolean
/**
* Reaching the minimum width or height is whether to allow control points to reverse
*/
allowReverse?: boolean
}
interface EventDataResizing {
action: 'resizing'
selector: 'bottomLeft' | 'bottomRight' | 'topRight' | 'topLeft'
direction: ResizeDirection
trueDirection: ResizeDirection
relativeDirection: ResizeDirection
resizeX: number
resizeY: number
angle: number
resized?: boolean
}
interface EventDataRotating {
action: 'rotating'
center: PointLike
angle: number
start: number
rotated?: boolean
}
export const NODE_CLS = 'has-widget-transform'
export const DIRECTIONS = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
export const POSITIONS: ResizeDirection[] = [
'top-left',
'top',
'top-right',
'right',
'bottom-right',
'bottom',
'bottom-left',
'left',
]
const defaultOptions: TransformImplOptions = {
minWidth: 0,
minHeight: 0,
maxWidth: Infinity,
maxHeight: Infinity,
rotateGrid: 15,
rotatable: true,
preserveAspectRatio: false,
orthogonalResizing: true,
restrictedResizing: false,
autoScrollOnResizing: true,
allowReverse: true,
}
export class TransformImpl extends View<TransformImplEventArgs> {
private node: Node
private graph: Graph
private options: TransformImplOptions
protected handle: Element | null
protected prevShift: number
public container: HTMLElement
protected get model() {
return this.graph.model
}
protected get view() {
return this.graph.renderer.findViewByCell(this.node)!
}
protected get containerClassName() {
return this.prefixClassName('widget-transform')
}
protected get resizeClassName() {
return `${this.containerClassName}-resize`
}
protected get rotateClassName() {
return `${this.containerClassName}-rotate`
}
constructor(options: TransformImplOptions, node: Node, graph: Graph) {
super()
this.node = node
this.graph = graph
this.options = {
...defaultOptions,
...options,
}
this.render()
this.startListening()
}
protected startListening() {
this.delegateEvents({
[`mousedown .${this.resizeClassName}`]: 'startResizing',
[`touchstart .${this.resizeClassName}`]: 'startResizing',
[`mousedown .${this.rotateClassName}`]: 'startRotating',
[`touchstart .${this.rotateClassName}`]: 'startRotating',
})
this.model.on('*', this.update, this)
this.graph.on('scale', this.update, this)
this.graph.on('translate', this.update, this)
this.node.on('removed', this.remove, this)
this.model.on('reseted', this.remove, this)
this.view.on('cell:knob:mousedown', this.onKnobMouseDown, this)
this.view.on('cell:knob:mouseup', this.onKnobMouseUp, this)
}
protected stopListening() {
this.undelegateEvents()
this.model.off('*', this.update, this)
this.graph.off('scale', this.update, this)
this.graph.off('translate', this.update, this)
this.node.off('removed', this.remove, this)
this.model.off('reseted', this.remove, this)
this.view.off('cell:knob:mousedown', this.onKnobMouseDown, this)
this.view.off('cell:knob:mouseup', this.onKnobMouseUp, this)
}
protected renderHandles() {
this.container = document.createElement('div')
const knob = document.createElement('div')
Dom.attr(knob, 'draggable', 'false')
const rotate = knob.cloneNode(true) as Element
Dom.addClass(rotate, this.rotateClassName)
const resizes = POSITIONS.map((pos) => {
const elem = knob.cloneNode(true) as Element
Dom.addClass(elem, this.resizeClassName)
Dom.attr(elem, 'data-position', pos)
return elem
})
this.empty()
Dom.append(this.container, [...resizes, rotate])
}
render() {
this.renderHandles()
if (this.view) {
this.view.addClass(NODE_CLS)
}
Dom.addClass(this.container, this.containerClassName)
Dom.toggleClass(
this.container,
'no-orth-resize',
this.options.preserveAspectRatio || !this.options.orthogonalResizing,
)
Dom.toggleClass(this.container, 'no-resize', !this.options.resizable)
Dom.toggleClass(this.container, 'no-rotate', !this.options.rotatable)
if (this.options.className) {
Dom.addClass(this.container, this.options.className)
}
this.graph.container.appendChild(this.container)
return this.update()
}
update() {
const ctm = this.graph.matrix()
const bbox = this.node.getBBox()
bbox.x *= ctm.a
bbox.x += ctm.e
bbox.y *= ctm.d
bbox.y += ctm.f
bbox.width *= ctm.a
bbox.height *= ctm.d
const angle = Angle.normalize(this.node.getAngle())
const transform = angle !== 0 ? `rotate(${angle}deg)` : ''
Dom.css(this.container, {
transform,
width: bbox.width,
height: bbox.height,
left: bbox.x,
top: bbox.y,
})
this.updateResizerDirections()
return this
}
remove() {
if (this.view) {
this.view.removeClass(NODE_CLS)
}
return super.remove()
}
protected onKnobMouseDown() {
this.startHandle()
}
protected onKnobMouseUp() {
this.stopHandle()
}
protected updateResizerDirections() {
// Update the directions on the resizer divs while the node being rotated.
// The directions are represented by cardinal points (N,S,E,W). For example
// the div originally pointed to north needs to be changed to point to south
// if the node was rotated by 180 degrees.
const angle = Angle.normalize(this.node.getAngle())
const shift = Math.floor(angle * (DIRECTIONS.length / 360))
if (shift !== this.prevShift) {
// Create the current directions array based on the calculated shift.
const directions = DIRECTIONS.slice(shift).concat(
DIRECTIONS.slice(0, shift),
)
const className = (dir: string) =>
`${this.containerClassName}-cursor-${dir}`
const resizes = this.container.querySelectorAll(
`.${this.resizeClassName}`,
)
resizes.forEach((resize, index) => {
Dom.removeClass(
resize,
DIRECTIONS.map((dir) => className(dir)).join(' '),
)
Dom.addClass(resize, className(directions[index]))
})
this.prevShift = shift
}
}
protected getTrueDirection(dir: ResizeDirection) {
const angle = Angle.normalize(this.node.getAngle())
let index = POSITIONS.indexOf(dir)
index += Math.floor(angle * (POSITIONS.length / 360))
index %= POSITIONS.length
return POSITIONS[index]
}
protected toValidResizeDirection(dir: string): ResizeDirection {
return (
(
{
top: 'top-left',
bottom: 'bottom-right',
left: 'bottom-left',
right: 'top-right',
} as KeyValue
)[dir] || dir
)
}
protected startResizing(evt: Dom.MouseDownEvent) {
evt.stopPropagation()
this.model.startBatch('resize', { cid: this.cid })
const dir = Dom.attr(evt.target, 'data-position') as ResizeDirection
this.prepareResizing(evt, dir)
this.startAction(evt)
}
protected prepareResizing(
evt: Dom.EventObject,
relativeDirection: ResizeDirection,
) {
const trueDirection = this.getTrueDirection(relativeDirection)
let rx = 0
let ry = 0
relativeDirection.split('-').forEach((direction) => {
rx = ({ left: -1, right: 1 } as KeyValue)[direction] || rx
ry = ({ top: -1, bottom: 1 } as KeyValue)[direction] || ry
})
const direction = this.toValidResizeDirection(relativeDirection)
const selector = (
{
'top-right': 'bottomLeft',
'top-left': 'bottomRight',
'bottom-left': 'topRight',
'bottom-right': 'topLeft',
} as KeyValue
)[direction]
const angle = Angle.normalize(this.node.getAngle())
this.setEventData<EventDataResizing>(evt, {
selector,
direction,
trueDirection,
relativeDirection,
angle,
resizeX: rx,
resizeY: ry,
action: 'resizing',
})
}
protected startRotating(evt: Dom.MouseDownEvent) {
evt.stopPropagation()
this.model.startBatch('rotate', { cid: this.cid })
const center = this.node.getBBox().getCenter()
const e = this.normalizeEvent(evt)
const client = this.graph.snapToGrid(e.clientX, e.clientY)
this.setEventData<EventDataRotating>(evt, {
center,
action: 'rotating',
angle: Angle.normalize(this.node.getAngle()),
start: Point.create(client).theta(center),
})
this.startAction(evt)
}
protected onMouseMove(evt: Dom.MouseMoveEvent) {
const view = this.graph.findViewByCell(this.node) as NodeView
let data = this.getEventData<EventDataResizing | EventDataRotating>(evt)
if (data.action) {
const e = this.normalizeEvent(evt)
let clientX = e.clientX
let clientY = e.clientY
const scroller = this.graph.getPlugin<Scroller>('scroller')
const restrict = this.options.restrictedResizing
if (restrict === true || typeof restrict === 'number') {
const factor = restrict === true ? 0 : restrict
const fix = scroller ? Math.max(factor, 8) : factor
const rect = this.graph.container.getBoundingClientRect()
clientX = NumberExt.clamp(clientX, rect.left + fix, rect.right - fix)
clientY = NumberExt.clamp(clientY, rect.top + fix, rect.bottom - fix)
} else if (this.options.autoScrollOnResizing && scroller) {
scroller.autoScroll(clientX, clientY)
}
const pos = this.graph.snapToGrid(clientX, clientY)
const gridSize = this.graph.getGridSize()
const node = this.node
const options = this.options
if (data.action === 'resizing') {
data = data as EventDataResizing
if (!data.resized) {
if (view) {
view.addClass('node-resizing')
this.notify('node:resize', evt, view)
}
data.resized = true
}
const currentBBox = node.getBBox()
const requestedSize = Point.create(pos)
.rotate(data.angle, currentBBox.getCenter())
.diff(currentBBox[data.selector])
let width = data.resizeX
? requestedSize.x * data.resizeX
: currentBBox.width
let height = data.resizeY
? requestedSize.y * data.resizeY
: currentBBox.height
const rawWidth = width
const rawHeight = height
width = snapToGrid(width, gridSize)
height = snapToGrid(height, gridSize)
width = Math.max(width, options.minWidth || gridSize)
height = Math.max(height, options.minHeight || gridSize)
width = Math.min(width, options.maxWidth || Infinity)
height = Math.min(height, options.maxHeight || Infinity)
if (options.preserveAspectRatio) {
const candidateWidth =
(currentBBox.width * height) / currentBBox.height
const candidateHeight =
(currentBBox.height * width) / currentBBox.width
if (width < candidateWidth) {
height = candidateHeight
} else {
width = candidateWidth
}
}
const relativeDirection = data.relativeDirection
if (
options.allowReverse &&
(rawWidth <= -width || rawHeight <= -height)
) {
let reverted: ResizeDirection
if (relativeDirection === 'left') {
if (rawWidth <= -width) {
reverted = 'right'
}
} else if (relativeDirection === 'right') {
if (rawWidth <= -width) {
reverted = 'left'
}
} else if (relativeDirection === 'top') {
if (rawHeight <= -height) {
reverted = 'bottom'
}
} else if (relativeDirection === 'bottom') {
if (rawHeight <= -height) {
reverted = 'top'
}
} else if (relativeDirection === 'top-left') {
if (rawWidth <= -width && rawHeight <= -height) {
reverted = 'bottom-right'
} else if (rawWidth <= -width) {
reverted = 'top-right'
} else if (rawHeight <= -height) {
reverted = 'bottom-left'
}
} else if (relativeDirection === 'top-right') {
if (rawWidth <= -width && rawHeight <= -height) {
reverted = 'bottom-left'
} else if (rawWidth <= -width) {
reverted = 'top-left'
} else if (rawHeight <= -height) {
reverted = 'bottom-right'
}
} else if (relativeDirection === 'bottom-left') {
if (rawWidth <= -width && rawHeight <= -height) {
reverted = 'top-right'
} else if (rawWidth <= -width) {
reverted = 'bottom-right'
} else if (rawHeight <= -height) {
reverted = 'top-left'
}
} else if (relativeDirection === 'bottom-right') {
if (rawWidth <= -width && rawHeight <= -height) {
reverted = 'top-left'
} else if (rawWidth <= -width) {
reverted = 'bottom-left'
} else if (rawHeight <= -height) {
reverted = 'top-right'
}
}
const revertedDir = reverted
this.stopHandle()
const handle = this.container.querySelector(
`.${this.resizeClassName}[data-position="${revertedDir}"]`,
)
this.startHandle(handle)
this.prepareResizing(evt, revertedDir)
this.onMouseMove(evt)
}
if (currentBBox.width !== width || currentBBox.height !== height) {
const resizeOptions: ResizeOptions = {
ui: true,
direction: data.direction,
relativeDirection: data.relativeDirection,
trueDirection: data.trueDirection,
minWidth: options.minWidth,
minHeight: options.minHeight,
maxWidth: options.maxWidth!,
maxHeight: options.maxHeight,
preserveAspectRatio: options.preserveAspectRatio === true,
}
node.resize(width, height, resizeOptions)
this.notify('node:resizing', evt, view)
}
} else if (data.action === 'rotating') {
data = data as EventDataRotating
if (!data.rotated) {
if (view) {
view.addClass('node-rotating')
this.notify('node:rotate', evt, view)
}
data.rotated = true
}
const currentAngle = node.getAngle()
const theta = data.start - Point.create(pos).theta(data.center)
let target = data.angle + theta
if (options.rotateGrid) {
target = snapToGrid(target, options.rotateGrid)
}
target = Angle.normalize(target)
if (currentAngle !== target) {
node.rotate(target, { absolute: true })
this.notify('node:rotating', evt, view)
}
}
}
}
protected onMouseUp(evt: Dom.MouseUpEvent) {
const data = this.getEventData<EventDataResizing | EventDataRotating>(evt)
if (data.action) {
this.stopAction(evt)
this.model.stopBatch(data.action === 'resizing' ? 'resize' : 'rotate', {
cid: this.cid,
})
}
}
protected startHandle(handle?: Element | null) {
this.handle = handle || null
Dom.addClass(this.container, `${this.containerClassName}-active`)
if (handle) {
Dom.addClass(handle, `${this.containerClassName}-active-handle`)
const pos = handle.getAttribute('data-position') as ResizeDirection
if (pos) {
const dir = DIRECTIONS[POSITIONS.indexOf(pos)]
Dom.addClass(this.container, `${this.containerClassName}-cursor-${dir}`)
}
}
}
protected stopHandle() {
Dom.removeClass(this.container, `${this.containerClassName}-active`)
if (this.handle) {
Dom.removeClass(this.handle, `${this.containerClassName}-active-handle`)
const pos = this.handle.getAttribute('data-position') as ResizeDirection
if (pos) {
const dir = DIRECTIONS[POSITIONS.indexOf(pos)]
Dom.removeClass(
this.container,
`${this.containerClassName}-cursor-${dir}`,
)
}
this.handle = null
}
}
protected startAction(evt: Dom.MouseDownEvent) {
this.startHandle(evt.target)
this.graph.view.undelegateEvents()
this.delegateDocumentEvents(DocumentEvents, evt.data)
}
protected stopAction(evt: Dom.MouseUpEvent) {
this.stopHandle()
this.undelegateDocumentEvents()
this.graph.view.delegateEvents()
const view = this.graph.findViewByCell(this.node) as NodeView
const data = this.getEventData<EventDataResizing | EventDataRotating>(evt)
if (view) {
view.removeClass(`node-${data.action}`)
if (data.action === 'resizing' && data.resized) {
this.notify('node:resized', evt, view)
} else if (data.action === 'rotating' && data.rotated) {
this.notify('node:rotated', evt, view)
}
}
}
protected notify<
K extends keyof TransformImplEventArgs,
T extends Dom.EventObject,
>(name: K, evt: T, view: NodeView, args: KeyValue = {}) {
if (view) {
const graph = view.graph
const e = graph.view.normalizeEvent(evt)
const localPoint = graph.snapToGrid(e.clientX, e.clientY)
this.trigger(name, {
e,
view,
node: view.cell,
cell: view.cell,
x: localPoint.x,
y: localPoint.y,
...args,
})
}
}
dispose() {
this.stopListening()
this.remove()
this.off()
}
}