@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering
521 lines (468 loc) • 14.6 kB
text/typescript
import {
ArrayExt,
Dictionary,
Dom,
FunctionExt,
ObjectExt,
StringExt,
Util,
} from '../common'
import { Point, type Rectangle } from '../geometry'
import {
type AttrDefinition,
type AttrPositionDefinition,
type CellAttrs,
type ComplexAttrs,
isValidDefinition,
type OffsetDefinition,
type SetDefinition,
type SimpleAttrs,
type SimpleAttrValue,
} from '../registry/attr'
import type { CellView } from './cell'
import type { MarkupSelectors } from './markup'
import { viewFind } from './view/util'
export interface AttrManagerUpdateOptions {
rootBBox: Rectangle
selectors: MarkupSelectors
scalableNode?: Element | null
rotatableNode?: Element | null
/**
* Rendering only the specified attributes.
*/
attrs?: CellAttrs | null
}
export interface AttrManagerProcessedAttrs {
raw: ComplexAttrs
normal?: SimpleAttrs | undefined
set?: ComplexAttrs | undefined
offset?: ComplexAttrs | undefined
position?: ComplexAttrs | undefined
}
export class AttrManager {
constructor(protected view: CellView) {}
protected get cell() {
return this.view.cell
}
protected getDefinition(attrName: string): AttrDefinition | null {
return this.cell.getAttrDefinition(attrName)
}
protected processAttrs(
elem: Element,
raw: ComplexAttrs,
): AttrManagerProcessedAttrs {
let normal: SimpleAttrs | undefined
let set: ComplexAttrs | undefined
let offset: ComplexAttrs | undefined
let position: ComplexAttrs | undefined
const specials: { name: string; definition: AttrDefinition }[] = []
// divide the attributes between normal and special
Object.keys(raw).forEach((name) => {
const val = raw[name]
const definition = this.getDefinition(name)
const isValid = FunctionExt.call(
isValidDefinition,
this.view,
definition,
val,
{
elem,
attrs: raw,
cell: this.cell,
view: this.view,
},
)
if (definition && isValid) {
if (typeof definition === 'string') {
if (normal == null) {
normal = {}
}
normal[definition] = val as SimpleAttrValue
} else if (val !== null) {
specials.push({ name, definition })
}
} else {
if (normal == null) {
normal = {}
}
const normalName = Dom.CASE_SENSITIVE_ATTR.includes(name)
? name
: StringExt.kebabCase(name)
normal[normalName] = val as SimpleAttrValue
}
})
specials.forEach(({ name, definition }) => {
const val = raw[name]
const setDefine = definition as SetDefinition
if (typeof setDefine.set === 'function') {
if (set == null) {
set = {}
}
set[name] = val
}
const offsetDefine = definition as OffsetDefinition
if (typeof offsetDefine.offset === 'function') {
if (offset == null) {
offset = {}
}
offset[name] = val
}
const positionDefine = definition as AttrPositionDefinition
if (typeof positionDefine.position === 'function') {
if (position == null) {
position = {}
}
position[name] = val
}
})
return {
raw,
normal,
set,
offset,
position,
}
}
protected mergeProcessedAttrs(
allProcessedAttrs: AttrManagerProcessedAttrs,
roProcessedAttrs: AttrManagerProcessedAttrs,
) {
allProcessedAttrs.set = {
...allProcessedAttrs.set,
...roProcessedAttrs.set,
}
allProcessedAttrs.position = {
...allProcessedAttrs.position,
...roProcessedAttrs.position,
}
allProcessedAttrs.offset = {
...allProcessedAttrs.offset,
...roProcessedAttrs.offset,
}
// Handle also the special transform property.
const transform = allProcessedAttrs.normal?.transform
if (transform != null && roProcessedAttrs.normal) {
roProcessedAttrs.normal.transform = transform
}
allProcessedAttrs.normal = roProcessedAttrs.normal
}
protected findAttrs(
cellAttrs: CellAttrs,
rootNode: Element,
selectorCache: { [selector: string]: Element[] },
selectors: MarkupSelectors,
) {
const merge: Element[] = []
const result: Dictionary<
Element,
{
elem: Element
array: boolean
priority: number | number[]
attrs: ComplexAttrs | ComplexAttrs[]
}
> = new Dictionary()
Object.keys(cellAttrs).forEach((selector) => {
const attrs = cellAttrs[selector]
if (!ObjectExt.isPlainObject(attrs)) {
return
}
const { isCSSSelector, elems } = viewFind(selector, rootNode, selectors)
selectorCache[selector] = elems
for (let i = 0, l = elems.length; i < l; i += 1) {
const elem = elems[i]
const unique = selectors && selectors[selector] === elem
const prev = result.get(elem)
if (prev) {
if (!prev.array) {
merge.push(elem)
prev.array = true
prev.attrs = [prev.attrs as ComplexAttrs]
prev.priority = [prev.priority as number]
}
const attributes = prev.attrs as ComplexAttrs[]
const selectedLength = prev.priority as number[]
if (unique) {
// node referenced by `selector`
attributes.unshift(attrs)
selectedLength.unshift(-1)
} else {
// node referenced by `groupSelector` or CSSSelector
const sortIndex = ArrayExt.sortedIndex(
selectedLength,
isCSSSelector ? -1 : l,
)
attributes.splice(sortIndex, 0, attrs)
selectedLength.splice(sortIndex, 0, l)
}
} else {
result.set(elem, {
elem,
attrs,
priority: unique ? -1 : l,
array: false,
})
}
}
})
merge.forEach((node) => {
const item = result.get(node)!
const arr = item.attrs as ComplexAttrs[]
item.attrs = arr.reduceRight(
(memo, attrs) => ObjectExt.merge(memo, attrs),
{},
)
})
return result as Dictionary<
Element,
{
elem: Element
array: boolean
priority: number | number[]
attrs: ComplexAttrs
}
>
}
protected updateRelativeAttrs(
elem: Element,
processedAttrs: AttrManagerProcessedAttrs,
refBBox: Rectangle,
) {
const rawAttrs = processedAttrs.raw || {}
let nodeAttrs = processedAttrs.normal || {}
const setAttrs = processedAttrs.set
const positionAttrs = processedAttrs.position
const offsetAttrs = processedAttrs.offset
const getOptions = () => ({
elem,
cell: this.cell,
view: this.view,
attrs: rawAttrs,
refBBox: refBBox.clone(),
})
if (setAttrs != null) {
Object.keys(setAttrs).forEach((name) => {
const val = setAttrs[name]
const def = this.getDefinition(name)
if (def != null) {
const ret = FunctionExt.call(
(def as SetDefinition).set,
this.view,
val,
getOptions(),
)
if (typeof ret === 'object') {
nodeAttrs = {
...nodeAttrs,
...ret,
}
} else if (ret != null) {
// @ts-expect-error
nodeAttrs[name] = ret
}
}
})
}
if (elem instanceof HTMLElement) {
// TODO: setting the `transform` attribute on HTMLElements
// via `node.style.transform = 'matrix(...)';` would introduce
// a breaking change (e.g. basic.TextBlock).
this.view.setAttrs(nodeAttrs, elem)
return
}
// The final translation of the subelement.
const nodeTransform = nodeAttrs.transform
const transform = nodeTransform ? `${nodeTransform}` : null
const nodeMatrix = Dom.transformStringToMatrix(transform)
const nodePosition = new Point(nodeMatrix.e, nodeMatrix.f)
if (nodeTransform) {
delete nodeAttrs.transform
nodeMatrix.e = 0
nodeMatrix.f = 0
}
let positioned = false
if (positionAttrs != null) {
Object.keys(positionAttrs).forEach((name) => {
const val = positionAttrs[name]
const def = this.getDefinition(name)
if (def != null) {
const ts = FunctionExt.call(
(def as AttrPositionDefinition).position,
this.view,
val,
getOptions(),
)
if (ts != null) {
positioned = true
nodePosition.translate(Point.create(ts))
}
}
})
}
// The node bounding box could depend on the `size`
// set from the previous loop.
this.view.setAttrs(nodeAttrs, elem)
let offseted = false
if (offsetAttrs != null) {
// Check if the node is visible
const nodeBoundingRect = this.view.getBoundingRectOfElement(elem)
if (nodeBoundingRect.width > 0 && nodeBoundingRect.height > 0) {
const nodeBBox = Util.transformRectangle(nodeBoundingRect, nodeMatrix)
Object.keys(offsetAttrs).forEach((name) => {
const val = offsetAttrs[name]
const def = this.getDefinition(name)
if (def != null) {
const ts = FunctionExt.call(
(def as OffsetDefinition).offset,
this.view,
val,
{
elem,
cell: this.cell,
view: this.view,
attrs: rawAttrs,
refBBox: nodeBBox,
},
)
if (ts != null) {
offseted = true
nodePosition.translate(Point.create(ts))
}
}
})
}
}
if (nodeTransform != null || positioned || offseted) {
nodePosition.round(1)
nodeMatrix.e = nodePosition.x
nodeMatrix.f = nodePosition.y
elem.setAttribute('transform', Dom.matrixToTransformString(nodeMatrix))
}
}
update(
rootNode: Element,
attrs: CellAttrs,
options: AttrManagerUpdateOptions,
) {
const selectorCache: { [selector: string]: Element[] } = {}
const nodesAttrs = this.findAttrs(
options.attrs || attrs,
rootNode,
selectorCache,
options.selectors,
)
// `nodesAttrs` are different from all attributes, when
// rendering only attributes sent to this method.
const nodesAllAttrs = options.attrs
? this.findAttrs(attrs, rootNode, selectorCache, options.selectors)
: nodesAttrs
const specialItems: {
node: Element
refNode: Element | null
attributes: ComplexAttrs | null
processedAttributes: AttrManagerProcessedAttrs
}[] = []
nodesAttrs.each((data) => {
const node = data.elem
const nodeAttrs = data.attrs
const processed = this.processAttrs(node, nodeAttrs)
if (
processed.set == null &&
processed.position == null &&
processed.offset == null
) {
this.view.setAttrs(processed.normal, node)
} else {
const data = nodesAllAttrs.get(node)
const nodeAllAttrs = data ? data.attrs : null
const refSelector =
nodeAllAttrs && nodeAttrs.ref == null
? nodeAllAttrs.ref
: nodeAttrs.ref
let refNode: Element | null
if (refSelector) {
refNode = (selectorCache[refSelector as string] ||
this.view.find(
refSelector as string,
rootNode,
options.selectors,
))[0]
if (!refNode) {
throw new Error(`"${refSelector}" reference does not exist.`)
}
} else {
refNode = null
}
const item = {
node,
refNode,
attributes: nodeAllAttrs,
processedAttributes: processed,
}
// If an element in the list is positioned relative to this one, then
// we want to insert this one before it in the list.
const index = specialItems.findIndex((item) => item.refNode === node)
if (index > -1) {
specialItems.splice(index, 0, item)
} else {
specialItems.push(item)
}
}
})
const bboxCache: Dictionary<Element, Rectangle> = new Dictionary()
let rotatableMatrix: DOMMatrix
specialItems.forEach((item) => {
const node = item.node
const refNode = item.refNode
let unrotatedRefBBox: Rectangle | undefined
const isRefNodeRotatable =
refNode != null &&
options.rotatableNode != null &&
Dom.contains(options.rotatableNode, refNode)
// Find the reference element bounding box. If no reference was
// provided, we use the optional bounding box.
if (refNode) {
unrotatedRefBBox = bboxCache.get(refNode)
}
if (!unrotatedRefBBox) {
const target = (
isRefNodeRotatable ? options.rotatableNode! : rootNode
) as SVGElement
unrotatedRefBBox = refNode
? Util.getBBox(refNode as SVGElement, { target })
: options.rootBBox
if (refNode) {
bboxCache.set(refNode, unrotatedRefBBox!)
}
}
let processedAttrs: AttrManagerProcessedAttrs
if (options.attrs && item.attributes) {
// If there was a special attribute affecting the position amongst
// passed-in attributes we have to merge it with the rest of the
// element's attributes as they are necessary to update the position
// relatively (i.e `ref-x` && 'ref-dx').
processedAttrs = this.processAttrs(node, item.attributes)
this.mergeProcessedAttrs(processedAttrs, item.processedAttributes)
} else {
processedAttrs = item.processedAttributes
}
let refBBox = unrotatedRefBBox!
if (
isRefNodeRotatable &&
options.rotatableNode != null &&
!options.rotatableNode.contains(node)
) {
// If the referenced node is inside the rotatable group while the
// updated node is outside, we need to take the rotatable node
// transformation into account.
if (!rotatableMatrix) {
rotatableMatrix = Dom.transformStringToMatrix(
Dom.attr(options.rotatableNode, 'transform'),
)
}
refBBox = Util.transformRectangle(unrotatedRefBBox!, rotatableMatrix)
}
this.updateRelativeAttrs(node, processedAttrs, refBBox)
})
}
}