@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering.
1,217 lines (1,059 loc) • 33.7 kB
text/typescript
import { Util } from '../../global'
import { KeyValue } from '../../types'
import { Rectangle, Angle, Point } from '../../geometry'
import { ObjectExt, StringExt, FunctionExt } from '../../util'
import { Cell } from '../../model/cell'
import { Node } from '../../model/node'
import { Edge } from '../../model/edge'
import { Model } from '../../model/model'
import { Collection } from '../../model/collection'
import { View } from '../../view/view'
import { CellView } from '../../view/cell'
import { NodeView } from '../../view/node'
import { Graph } from '../../graph/graph'
import { Renderer } from '../../graph/renderer'
import { notify } from '../transform/util'
import { Handle } from '../common'
export class Selection extends View<Selection.EventArgs> {
public readonly options: Selection.Options
protected readonly collection: Collection
protected $container: JQuery<HTMLElement>
protected $selectionContainer: JQuery<HTMLElement>
protected $selectionContent: JQuery<HTMLElement>
protected boxCount: number
protected boxesUpdated: boolean
public get graph() {
return this.options.graph
}
protected get boxClassName() {
return this.prefixClassName(Private.classNames.box)
}
protected get $boxes() {
return this.$container.children(`.${this.boxClassName}`)
}
protected get handleOptions() {
return this.options
}
constructor(options: Selection.Options) {
super()
this.options = ObjectExt.merge({}, Private.defaultOptions, options)
if (this.options.model) {
this.options.collection = this.options.model.collection
}
if (this.options.collection) {
this.collection = this.options.collection
} else {
this.collection = new Collection([], {
comparator: Private.depthComparator,
})
this.options.collection = this.collection
}
this.boxCount = 0
this.createContainer()
this.initHandles()
this.startListening()
}
protected startListening() {
const graph = this.graph
const collection = this.collection
this.delegateEvents(
{
[`mousedown .${this.boxClassName}`]: 'onSelectionBoxMouseDown',
[`touchstart .${this.boxClassName}`]: 'onSelectionBoxMouseDown',
},
true,
)
graph.on('scale', this.onGraphTransformed, this)
graph.on('translate', this.onGraphTransformed, this)
graph.model.on('updated', this.onModelUpdated, this)
collection.on('added', this.onCellAdded, this)
collection.on('removed', this.onCellRemoved, this)
collection.on('reseted', this.onReseted, this)
collection.on('updated', this.onCollectionUpdated, this)
collection.on('node:change:position', this.onNodePositionChanged, this)
collection.on('cell:changed', this.onCellChanged, this)
}
protected stopListening() {
const graph = this.graph
const collection = this.collection
this.undelegateEvents()
graph.off('scale', this.onGraphTransformed, this)
graph.off('translate', this.onGraphTransformed, this)
graph.model.off('updated', this.onModelUpdated, this)
collection.off('added', this.onCellAdded, this)
collection.off('removed', this.onCellRemoved, this)
collection.off('reseted', this.onReseted, this)
collection.off('updated', this.onCollectionUpdated, this)
collection.off('node:change:position', this.onNodePositionChanged, this)
collection.off('cell:changed', this.onCellChanged, this)
}
protected onRemove() {
this.stopListening()
}
protected onGraphTransformed() {
this.updateSelectionBoxes({ async: false })
}
protected onCellChanged() {
this.updateSelectionBoxes()
}
protected translating: boolean
protected onNodePositionChanged({
node,
options,
}: Collection.EventArgs['node:change:position']) {
const { showNodeSelectionBox, pointerEvents } = this.options
const { ui, selection } = options
let allowTranslating = !this.translating
/* Scenarios where this method is not called:
* 1. ShowNodeSelection is true or ponterEvents is none
* 2. Avoid circular calls with the selection tag
*/
allowTranslating =
allowTranslating &&
(showNodeSelectionBox !== true || pointerEvents === 'none')
allowTranslating = allowTranslating && ui && !selection
if (allowTranslating) {
this.translating = true
const current = node.position()
const previous = node.previous('position')!
const dx = current.x - previous.x
const dy = current.y - previous.y
if (dx !== 0 || dy !== 0) {
this.translateSelectedNodes(dx, dy, node, options)
}
this.translating = false
}
}
protected onModelUpdated({ removed }: Collection.EventArgs['updated']) {
if (removed && removed.length) {
this.unselect(removed)
}
}
isEmpty() {
return this.length <= 0
}
isSelected(cell: Cell | string) {
return this.collection.has(cell)
}
get length() {
return this.collection.length
}
get cells() {
return this.collection.toArray()
}
select(cells: Cell | Cell[], options: Collection.AddOptions = {}) {
options.dryrun = true
const items = this.filter(Array.isArray(cells) ? cells : [cells])
this.collection.add(items, options)
return this
}
unselect(cells: Cell | Cell[], options: Collection.RemoveOptions = {}) {
// dryrun to prevent cell be removed from graph
options.dryrun = true
this.collection.remove(Array.isArray(cells) ? cells : [cells], options)
return this
}
reset(cells?: Cell | Cell[], options: Collection.SetOptions = {}) {
if (cells) {
const prev = this.cells
const next = this.filter(Array.isArray(cells) ? cells : [cells])
const prevMap: KeyValue<Cell> = {}
const nextMap: KeyValue<Cell> = {}
prev.forEach((cell) => (prevMap[cell.id] = cell))
next.forEach((cell) => (nextMap[cell.id] = cell))
const added: Cell[] = []
const removed: Cell[] = []
next.forEach((cell) => {
if (!prevMap[cell.id]) {
added.push(cell)
}
})
prev.forEach((cell) => {
if (!nextMap[cell.id]) {
removed.push(cell)
}
})
if (removed.length) {
this.unselect(removed, { ...options, ui: true })
}
if (added.length) {
this.select(added, { ...options, ui: true })
}
if (removed.length === 0 && added.length === 0) {
this.updateContainer()
}
return this
}
return this.clean(options)
}
clean(options: Collection.SetOptions = {}) {
if (this.length) {
this.collection.reset([], { ...options, ui: true })
}
return this
}
setFilter(filter?: Selection.Filter) {
this.options.filter = filter
}
setContent(content?: Selection.Content) {
this.options.content = content
}
startSelecting(evt: JQuery.MouseDownEvent) {
// Flow: startSelecting => adjustSelection => stopSelecting
evt = this.normalizeEvent(evt) // eslint-disable-line
this.clean()
let x
let y
const graphContainer = this.graph.container
if (
evt.offsetX != null &&
evt.offsetY != null &&
graphContainer.contains(evt.target)
) {
x = evt.offsetX
y = evt.offsetY
} else {
const offset = this.$(graphContainer).offset()!
const scrollLeft = graphContainer.scrollLeft
const scrollTop = graphContainer.scrollTop
x = evt.clientX - offset.left + window.pageXOffset + scrollLeft
y = evt.clientY - offset.top + window.pageYOffset + scrollTop
}
this.$container.css({
top: y,
left: x,
width: 1,
height: 1,
})
this.setEventData<EventData.Selecting>(evt, {
action: 'selecting',
clientX: evt.clientX,
clientY: evt.clientY,
offsetX: x,
offsetY: y,
scrollerX: 0,
scrollerY: 0,
})
this.delegateDocumentEvents(Private.documentEvents, evt.data)
}
filter(cells: Cell[]) {
const filter = this.options.filter
if (Array.isArray(filter)) {
return cells.filter(
(cell) => !filter.includes(cell) && !filter.includes(cell.shape),
)
}
if (typeof filter === 'function') {
return cells.filter((cell) => FunctionExt.call(filter, this.graph, cell))
}
return cells
}
protected stopSelecting(evt: JQuery.MouseUpEvent) {
const graph = this.graph
const eventData = this.getEventData<EventData.Common>(evt)
const action = eventData.action
switch (action) {
case 'selecting': {
let width = this.$container.width()!
let height = this.$container.height()!
const offset = this.$container.offset()!
const origin = graph.pageToLocal(offset.left, offset.top)
const scale = graph.transform.getScale()
width /= scale.sx
height /= scale.sy
const rect = new Rectangle(origin.x, origin.y, width, height)
const cells = this.getCellViewsInArea(rect).map((view) => view.cell)
this.reset(cells)
this.hideRubberband()
break
}
case 'translating': {
const client = graph.snapToGrid(evt.clientX, evt.clientY)
if (!this.options.following) {
const data = eventData as EventData.Translating
this.updateSelectedNodesPosition({
dx: data.clientX - data.originX,
dy: data.clientY - data.originY,
})
}
this.graph.model.stopBatch('move-selection')
this.notifyBoxEvent('box:mouseup', evt, client.x, client.y)
break
}
default: {
this.clean()
break
}
}
}
protected onMouseUp(evt: JQuery.MouseUpEvent) {
const action = this.getEventData<EventData.Common>(evt).action
if (action) {
this.stopSelecting(evt)
this.undelegateDocumentEvents()
}
}
protected onSelectionBoxMouseDown(evt: JQuery.MouseDownEvent) {
// evt.stopPropagation()
const e = this.normalizeEvent(evt)
if (this.options.movable) {
this.startTranslating(e)
}
const activeView = this.getCellViewFromElem(e.target)!
this.setEventData<EventData.SelectionBox>(e, { activeView })
const client = this.graph.snapToGrid(e.clientX, e.clientY)
this.notifyBoxEvent('box:mousedown', e, client.x, client.y)
this.delegateDocumentEvents(Private.documentEvents, e.data)
}
protected startTranslating(evt: JQuery.MouseDownEvent) {
this.graph.model.startBatch('move-selection')
const client = this.graph.snapToGrid(evt.clientX, evt.clientY)
this.setEventData<EventData.Translating>(evt, {
action: 'translating',
clientX: client.x,
clientY: client.y,
originX: client.x,
originY: client.y,
})
}
protected getSelectionOffset(client: Point, data: EventData.Translating) {
let dx = client.x - data.clientX
let dy = client.y - data.clientY
const restrict = this.graph.hook.getRestrictArea()
if (restrict) {
const cells = this.collection.toArray()
const totalBBox =
Cell.getCellsBBox(cells, { deep: true }) || Rectangle.create()
const minDx = restrict.x - totalBBox.x
const minDy = restrict.y - totalBBox.y
const maxDx =
restrict.x + restrict.width - (totalBBox.x + totalBBox.width)
const maxDy =
restrict.y + restrict.height - (totalBBox.y + totalBBox.height)
if (dx < minDx) {
dx = minDx
}
if (dy < minDy) {
dy = minDy
}
if (maxDx < dx) {
dx = maxDx
}
if (maxDy < dy) {
dy = maxDy
}
if (!this.options.following) {
const offsetX = client.x - data.originX
const offsetY = client.y - data.originY
dx = offsetX <= minDx || offsetX >= maxDx ? 0 : dx
dy = offsetY <= minDy || offsetY >= maxDy ? 0 : dy
}
}
return {
dx,
dy,
}
}
protected updateSelectedNodesPosition(offset: { dx: number; dy: number }) {
const { dx, dy } = offset
if (dx || dy) {
if ((this.translateSelectedNodes(dx, dy), this.boxesUpdated)) {
if (this.collection.length > 1) {
this.updateSelectionBoxes()
}
} else {
const scale = this.graph.transform.getScale()
this.$boxes.add(this.$selectionContainer).css({
left: `+=${dx * scale.sx}`,
top: `+=${dy * scale.sy}`,
})
}
}
}
protected autoScrollGraph(x: number, y: number) {
const scroller = this.graph.scroller.widget
if (scroller) {
return scroller.autoScroll(x, y)
}
return { scrollerX: 0, scrollerY: 0 }
}
protected adjustSelection(evt: JQuery.MouseMoveEvent) {
const e = this.normalizeEvent(evt)
const eventData = this.getEventData<EventData.Common>(e)
const action = eventData.action
switch (action) {
case 'selecting': {
const data = eventData as EventData.Selecting
if (data.moving !== true) {
this.$container.appendTo(this.graph.container)
this.showRubberband()
data.moving = true
}
const { scrollerX, scrollerY } = this.autoScrollGraph(
e.clientX,
e.clientY,
)
data.scrollerX += scrollerX
data.scrollerY += scrollerY
const dx = e.clientX - data.clientX + data.scrollerX
const dy = e.clientY - data.clientY + data.scrollerY
const left = parseInt(this.$container.css('left'), 10)
const top = parseInt(this.$container.css('top'), 10)
this.$container.css({
left: dx < 0 ? data.offsetX + dx : left,
top: dy < 0 ? data.offsetY + dy : top,
width: Math.abs(dx),
height: Math.abs(dy),
})
break
}
case 'translating': {
const client = this.graph.snapToGrid(e.clientX, e.clientY)
const data = eventData as EventData.Translating
const offset = this.getSelectionOffset(client, data)
if (this.options.following) {
this.updateSelectedNodesPosition(offset)
} else {
this.updateContainerPosition(offset)
}
if (offset.dx) {
data.clientX = client.x
}
if (offset.dy) {
data.clientY = client.y
}
this.notifyBoxEvent('box:mousemove', evt, client.x, client.y)
break
}
default:
break
}
this.boxesUpdated = false
}
protected translateSelectedNodes(
dx: number,
dy: number,
exclude?: Cell,
otherOptions?: KeyValue,
) {
const map: { [id: string]: boolean } = {}
const excluded: Cell[] = []
if (exclude) {
map[exclude.id] = true
}
this.collection.toArray().forEach((cell) => {
cell.getDescendants({ deep: true }).forEach((child) => {
map[child.id] = true
})
})
if (otherOptions && otherOptions.translateBy) {
const currentCell = this.graph.getCellById(otherOptions.translateBy)
if (currentCell) {
map[currentCell.id] = true
currentCell.getDescendants({ deep: true }).forEach((child) => {
map[child.id] = true
})
excluded.push(currentCell)
}
}
this.collection.toArray().forEach((cell) => {
if (!map[cell.id]) {
const options = {
...otherOptions,
selection: this.cid,
exclude: excluded,
}
cell.translate(dx, dy, options)
this.graph.model.getConnectedEdges(cell).forEach((edge) => {
if (!map[edge.id]) {
edge.translate(dx, dy, options)
map[edge.id] = true
}
})
}
})
}
protected getCellViewsInArea(rect: Rectangle) {
const graph = this.graph
const options = {
strict: this.options.strict,
}
let views: CellView[] = []
if (this.options.rubberNode) {
if (this.options.useCellGeometry) {
views = views.concat(
graph.model
.getNodesInArea(rect, options)
.map((node) => graph.renderer.findViewByCell(node))
.filter((view) => view != null) as CellView[],
)
} else {
views = views.concat(graph.renderer.findViewsInArea(rect, options))
}
}
if (this.options.rubberEdge) {
if (this.options.useCellGeometry) {
views = views.concat(
graph.model
.getEdgesInArea(rect, options)
.map((edge) => graph.renderer.findViewByCell(edge))
.filter((view) => view != null) as CellView[],
)
} else {
views = views.concat(graph.renderer.findEdgeViewsInArea(rect, options))
}
}
return views
}
protected notifyBoxEvent<
K extends keyof Selection.BoxEventArgs,
T extends JQuery.TriggeredEvent,
>(name: K, e: T, x: number, y: number) {
const data = this.getEventData<EventData.SelectionBox>(e)
const view = data.activeView
this.trigger(name, { e, view, x, y, cell: view.cell })
}
protected getSelectedClassName(cell: Cell) {
return this.prefixClassName(`${cell.isNode() ? 'node' : 'edge'}-selected`)
}
protected addCellSelectedClassName(cell: Cell) {
const view = this.graph.renderer.findViewByCell(cell)
if (view) {
view.addClass(this.getSelectedClassName(cell))
}
}
protected removeCellUnSelectedClassName(cell: Cell) {
const view = this.graph.renderer.findViewByCell(cell)
if (view) {
view.removeClass(this.getSelectedClassName(cell))
}
}
protected destroySelectionBox(cell: Cell) {
this.removeCellUnSelectedClassName(cell)
if (this.canShowSelectionBox(cell)) {
this.$container.find(`[data-cell-id="${cell.id}"]`).remove()
if (this.$boxes.length === 0) {
this.hide()
}
this.boxCount = Math.max(0, this.boxCount - 1)
}
}
protected destroyAllSelectionBoxes(cells: Cell[]) {
cells.forEach((cell) => this.removeCellUnSelectedClassName(cell))
this.hide()
this.$boxes.remove()
this.boxCount = 0
}
hide() {
this.$container
.removeClass(this.prefixClassName(Private.classNames.rubberband))
.removeClass(this.prefixClassName(Private.classNames.selected))
}
protected showRubberband() {
this.$container.addClass(
this.prefixClassName(Private.classNames.rubberband),
)
}
protected hideRubberband() {
this.$container.removeClass(
this.prefixClassName(Private.classNames.rubberband),
)
}
protected showSelected() {
this.$container
.removeAttr('style')
.addClass(this.prefixClassName(Private.classNames.selected))
}
protected createContainer() {
this.container = document.createElement('div')
this.$container = this.$(this.container)
this.$container.addClass(this.prefixClassName(Private.classNames.root))
if (this.options.className) {
this.$container.addClass(this.options.className)
}
this.$selectionContainer = this.$('<div/>').addClass(
this.prefixClassName(Private.classNames.inner),
)
this.$selectionContent = this.$('<div/>').addClass(
this.prefixClassName(Private.classNames.content),
)
this.$selectionContainer.append(this.$selectionContent)
this.$selectionContainer.attr(
'data-selection-length',
this.collection.length,
)
this.$container.prepend(this.$selectionContainer)
this.$handleContainer = this.$selectionContainer
}
protected updateContainerPosition(offset: { dx: number; dy: number }) {
if (offset.dx || offset.dy) {
this.$selectionContainer.css({
left: `+=${offset.dx}`,
top: `+=${offset.dy}`,
})
}
}
protected updateContainer() {
const origin = { x: Infinity, y: Infinity }
const corner = { x: 0, y: 0 }
const cells = this.collection
.toArray()
.filter((cell) => this.canShowSelectionBox(cell))
cells.forEach((cell) => {
const view = this.graph.renderer.findViewByCell(cell)
if (view) {
const bbox = view.getBBox({
useCellGeometry: this.options.useCellGeometry,
})
origin.x = Math.min(origin.x, bbox.x)
origin.y = Math.min(origin.y, bbox.y)
corner.x = Math.max(corner.x, bbox.x + bbox.width)
corner.y = Math.max(corner.y, bbox.y + bbox.height)
}
})
this.$selectionContainer
.css({
position: 'absolute',
pointerEvents: 'none',
left: origin.x,
top: origin.y,
width: corner.x - origin.x,
height: corner.y - origin.y,
})
.attr('data-selection-length', this.collection.length)
const boxContent = this.options.content
if (boxContent) {
if (typeof boxContent === 'function') {
const content = FunctionExt.call(
boxContent,
this.graph,
this,
this.$selectionContent[0],
)
if (content) {
this.$selectionContent.html(content)
}
} else {
this.$selectionContent.html(boxContent)
}
}
if (this.collection.length > 0 && !this.container.parentNode) {
this.$container.appendTo(this.graph.container)
} else if (this.collection.length <= 0 && this.container.parentNode) {
this.container.parentNode.removeChild(this.container)
}
}
protected canShowSelectionBox(cell: Cell) {
return (
(cell.isNode() && this.options.showNodeSelectionBox === true) ||
(cell.isEdge() && this.options.showEdgeSelectionBox === true)
)
}
protected createSelectionBox(cell: Cell) {
this.addCellSelectedClassName(cell)
if (this.canShowSelectionBox(cell)) {
const view = this.graph.renderer.findViewByCell(cell)
if (view) {
const bbox = view.getBBox({
useCellGeometry: this.options.useCellGeometry,
})
const className = this.boxClassName
this.$('<div/>')
.addClass(className)
.addClass(`${className}-${cell.isNode() ? 'node' : 'edge'}`)
.attr('data-cell-id', cell.id)
.css({
position: 'absolute',
left: bbox.x,
top: bbox.y,
width: bbox.width,
height: bbox.height,
pointerEvents: this.options.pointerEvents || 'auto',
})
.appendTo(this.container)
this.showSelected()
this.boxCount += 1
}
}
}
protected updateSelectionBoxes(
options: Renderer.RequestViewUpdateOptions = {},
) {
if (this.collection.length > 0) {
this.boxesUpdated = true
this.graph.renderer.requestViewUpdate(this as any, 1, 2, options)
}
}
confirmUpdate() {
if (this.boxCount) {
this.hide()
this.$boxes.each((_, elem) => {
const cellId = this.$(elem).remove().attr('data-cell-id')
const cell = this.collection.get(cellId)
if (cell) {
this.createSelectionBox(cell)
}
})
this.updateContainer()
}
return 0
}
protected getCellViewFromElem(elem: Element) {
const id = elem.getAttribute('data-cell-id')
if (id) {
const cell = this.collection.get(id)
if (cell) {
return this.graph.renderer.findViewByCell(cell)
}
}
return null
}
protected onCellRemoved({ cell }: Collection.EventArgs['removed']) {
this.destroySelectionBox(cell)
this.updateContainer()
}
protected onReseted({ previous, current }: Collection.EventArgs['reseted']) {
this.destroyAllSelectionBoxes(previous)
current.forEach((cell) => {
this.listenCellRemoveEvent(cell)
this.createSelectionBox(cell)
})
this.updateContainer()
}
protected onCellAdded({ cell }: Collection.EventArgs['added']) {
// The collection do not known the cell was removed when cell was
// removed by interaction(such as, by "delete" shortcut), so we should
// manually listen to cell's remove evnet.
this.listenCellRemoveEvent(cell)
this.createSelectionBox(cell)
this.updateContainer()
}
protected listenCellRemoveEvent(cell: Cell) {
cell.off('removed', this.onCellRemoved, this)
cell.on('removed', this.onCellRemoved, this)
}
protected onCollectionUpdated({
added,
removed,
options,
}: Collection.EventArgs['updated']) {
added.forEach((cell) => {
this.trigger('cell:selected', { cell, options })
this.graph.trigger('cell:selected', { cell, options })
if (cell.isNode()) {
this.trigger('node:selected', { cell, options, node: cell })
this.graph.trigger('node:selected', { cell, options, node: cell })
} else if (cell.isEdge()) {
this.trigger('edge:selected', { cell, options, edge: cell })
this.graph.trigger('edge:selected', { cell, options, edge: cell })
}
})
removed.forEach((cell) => {
this.trigger('cell:unselected', { cell, options })
this.graph.trigger('cell:unselected', { cell, options })
if (cell.isNode()) {
this.trigger('node:unselected', { cell, options, node: cell })
this.graph.trigger('node:unselected', { cell, options, node: cell })
} else if (cell.isEdge()) {
this.trigger('edge:unselected', { cell, options, edge: cell })
this.graph.trigger('edge:unselected', { cell, options, edge: cell })
}
})
const args = {
added,
removed,
options,
selected: this.cells,
}
this.trigger('selection:changed', args)
this.graph.trigger('selection:changed', args)
}
// #region handle
protected deleteSelectedCells() {
const cells = this.collection.toArray()
this.clean()
this.graph.model.removeCells(cells, { selection: this.cid })
}
protected startRotate({ e }: Handle.EventArgs) {
const cells = this.collection.toArray()
const center = Cell.getCellsBBox(cells)!.getCenter()
const client = this.graph.snapToGrid(e.clientX!, e.clientY!)
const angles = cells.reduce<{ [id: string]: number }>(
(memo, cell: Node) => {
memo[cell.id] = Angle.normalize(cell.getAngle())
return memo
},
{},
)
this.setEventData<EventData.Rotation>(e, {
center,
angles,
start: client.theta(center),
})
}
protected doRotate({ e }: Handle.EventArgs) {
const data = this.getEventData<EventData.Rotation>(e)
const grid = this.graph.options.rotating.grid
const gridSize =
typeof grid === 'function'
? FunctionExt.call(grid, this.graph, null as any)
: grid
const client = this.graph.snapToGrid(e.clientX!, e.clientY!)
const delta = data.start - client.theta(data.center)
if (!data.rotated) {
data.rotated = true
}
if (Math.abs(delta) > 0.001) {
this.collection.toArray().forEach((node: Node) => {
const angle = Util.snapToGrid(
data.angles[node.id] + delta,
gridSize || 15,
)
node.rotate(angle, {
absolute: true,
center: data.center,
selection: this.cid,
})
})
this.updateSelectionBoxes()
}
}
protected stopRotate({ e }: Handle.EventArgs) {
const data = this.getEventData<EventData.Rotation>(e)
if (data.rotated) {
data.rotated = false
this.collection.toArray().forEach((node: Node) => {
notify(
'node:rotated',
e as JQuery.MouseUpEvent,
this.graph.findViewByCell(node) as NodeView,
)
})
}
}
protected startResize({ e }: Handle.EventArgs) {
const gridSize = this.graph.getGridSize()
const cells = this.collection.toArray()
const bbox = Cell.getCellsBBox(cells)!
const bboxes = cells.map((cell) => cell.getBBox())
const maxWidth = bboxes.reduce((maxWidth, bbox) => {
return bbox.width < maxWidth ? bbox.width : maxWidth
}, Infinity)
const maxHeight = bboxes.reduce((maxHeight, bbox) => {
return bbox.height < maxHeight ? bbox.height : maxHeight
}, Infinity)
this.setEventData<EventData.Resizing>(e, {
bbox,
cells: this.graph.model.getSubGraph(cells),
minWidth: (gridSize * bbox.width) / maxWidth,
minHeight: (gridSize * bbox.height) / maxHeight,
})
}
protected doResize({ e, dx, dy }: Handle.EventArgs) {
const data = this.eventData<EventData.Resizing>(e)
const bbox = data.bbox
const width = bbox.width
const height = bbox.height
const newWidth = Math.max(width + dx, data.minWidth)
const newHeight = Math.max(height + dy, data.minHeight)
if (!data.resized) {
data.resized = true
}
if (
Math.abs(width - newWidth) > 0.001 ||
Math.abs(height - newHeight) > 0.001
) {
this.graph.model.resizeCells(newWidth, newHeight, data.cells, {
selection: this.cid,
})
bbox.width = newWidth
bbox.height = newHeight
this.updateSelectionBoxes()
}
}
protected stopResize({ e }: Handle.EventArgs) {
const data = this.eventData<EventData.Resizing>(e)
if (data.resized) {
data.resized = false
this.collection.toArray().forEach((node: Node) => {
notify(
'node:resized',
e as JQuery.MouseUpEvent,
this.graph.findViewByCell(node) as NodeView,
)
})
}
}
// #endregion
.dispose()
dispose() {
this.clean()
this.remove()
}
}
export namespace Selection {
export interface CommonOptions extends Handle.Options {
model?: Model
collection?: Collection
className?: string
strict?: boolean
filter?: Filter
showEdgeSelectionBox?: boolean
showNodeSelectionBox?: boolean
movable?: boolean
following?: boolean
useCellGeometry?: boolean
content?: Content
// Can select node or edge when rubberband
rubberNode?: boolean
rubberEdge?: boolean
// Whether to respond event on the selectionBox
pointerEvents?: 'none' | 'auto'
}
export interface Options extends CommonOptions {
graph: Graph
}
export type Content =
| null
| false
| string
| ((
this: Graph,
selection: Selection,
contentElement: HTMLElement,
) => string)
export type Filter =
| null
| (string | { id: string })[]
| ((this: Graph, cell: Cell) => boolean)
}
export namespace Selection {
interface SelectionBoxEventArgs<T> {
e: T
view: CellView
cell: Cell
x: number
y: number
}
export interface BoxEventArgs {
'box:mousedown': SelectionBoxEventArgs<JQuery.MouseDownEvent>
'box:mousemove': SelectionBoxEventArgs<JQuery.MouseMoveEvent>
'box:mouseup': SelectionBoxEventArgs<JQuery.MouseUpEvent>
}
export interface SelectionEventArgs {
'cell:selected': { cell: Cell; options: Model.SetOptions }
'node:selected': { cell: Cell; node: Node; options: Model.SetOptions }
'edge:selected': { cell: Cell; edge: Edge; options: Model.SetOptions }
'cell:unselected': { cell: Cell; options: Model.SetOptions }
'node:unselected': { cell: Cell; node: Node; options: Model.SetOptions }
'edge:unselected': { cell: Cell; edge: Edge; options: Model.SetOptions }
'selection:changed': {
added: Cell[]
removed: Cell[]
selected: Cell[]
options: Model.SetOptions
}
}
export interface EventArgs extends BoxEventArgs, SelectionEventArgs {}
}
export interface Selection extends Handle {}
ObjectExt.applyMixins(Selection, Handle)
// private
// -------
namespace Private {
const base = 'widget-selection'
export const classNames = {
root: base,
inner: `${base}-inner`,
box: `${base}-box`,
content: `${base}-content`,
rubberband: `${base}-rubberband`,
selected: `${base}-selected`,
}
export const documentEvents = {
mousemove: 'adjustSelection',
touchmove: 'adjustSelection',
mouseup: 'onMouseUp',
touchend: 'onMouseUp',
touchcancel: 'onMouseUp',
}
export const defaultOptions: Partial<Selection.Options> = {
movable: true,
following: true,
strict: false,
useCellGeometry: false,
content(selection) {
return StringExt.template(
'<%= length %> node<%= length > 1 ? "s":"" %> selected.',
)({ length: selection.length })
},
handles: [
{
name: 'remove',
position: 'nw',
events: {
mousedown: 'deleteSelectedCells',
},
},
{
name: 'rotate',
position: 'sw',
events: {
mousedown: 'startRotate',
mousemove: 'doRotate',
mouseup: 'stopRotate',
},
},
{
name: 'resize',
position: 'se',
events: {
mousedown: 'startResize',
mousemove: 'doResize',
mouseup: 'stopResize',
},
},
],
}
export function depthComparator(cell: Cell) {
return cell.getAncestors().length
}
}
namespace EventData {
export interface Common {
action: 'selecting' | 'translating'
}
export interface Selecting extends Common {
action: 'selecting'
moving?: boolean
clientX: number
clientY: number
offsetX: number
offsetY: number
scrollerX: number
scrollerY: number
}
export interface Translating extends Common {
action: 'translating'
clientX: number
clientY: number
originX: number
originY: number
}
export interface SelectionBox {
activeView: CellView
}
export interface Rotation {
rotated?: boolean
center: Point.PointLike
start: number
angles: { [id: string]: number }
}
export interface Resizing {
resized?: boolean
bbox: Rectangle
cells: Cell[]
minWidth: number
minHeight: number
}
}