@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering.
595 lines (537 loc) • 16.7 kB
text/typescript
import {
ObjectExt,
ArrayExt,
Dom,
FunctionExt,
StringExt,
Scheduler,
} from '../util'
import { Rectangle, Point } from '../geometry'
import { Dictionary } from '../common'
import { Attr } from '../registry/attr'
import { View } from './view'
import { Markup } from './markup'
import { CellView } from './cell'
export class AttrManager {
constructor(protected view: CellView) {}
protected get cell() {
return this.view.cell
}
protected getDefinition(attrName: string): Attr.Definition | null {
return this.cell.getAttrDefinition(attrName)
}
protected processAttrs(
elem: Element,
raw: Attr.ComplexAttrs,
): AttrManager.ProcessedAttrs {
let normal: Attr.SimpleAttrs | undefined
let set: Attr.ComplexAttrs | undefined
let offset: Attr.ComplexAttrs | undefined
let position: Attr.ComplexAttrs | undefined
let delay: Attr.ComplexAttrs | undefined
const specials: { name: string; definition: Attr.Definition }[] = []
// 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(
Attr.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 Attr.SimpleAttrValue
} else if (val !== null) {
specials.push({ name, definition })
}
} else {
if (normal == null) {
normal = {}
}
const normalName = AttrManager.CASE_SENSITIVE_ATTR.includes(name)
? name
: StringExt.kebabCase(name)
normal[normalName] = val as Attr.SimpleAttrValue
}
})
specials.forEach(({ name, definition }) => {
const val = raw[name]
const setDefine = definition as Attr.SetDefinition
if (typeof setDefine.set === 'function') {
if (
!Dom.isHTMLElement(elem) &&
AttrManager.DELAY_ATTRS.includes(name)
) {
if (delay == null) {
delay = {}
}
delay[name] = val
} else {
if (set == null) {
set = {}
}
set[name] = val
}
}
const offsetDefine = definition as Attr.OffsetDefinition
if (typeof offsetDefine.offset === 'function') {
if (offset == null) {
offset = {}
}
offset[name] = val
}
const positionDefine = definition as Attr.PositionDefinition
if (typeof positionDefine.position === 'function') {
if (position == null) {
position = {}
}
position[name] = val
}
})
return {
raw,
normal,
set,
offset,
position,
delay,
}
}
protected mergeProcessedAttrs(
allProcessedAttrs: AttrManager.ProcessedAttrs,
roProcessedAttrs: AttrManager.ProcessedAttrs,
) {
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 && allProcessedAttrs.normal.transform
if (transform != null && roProcessedAttrs.normal) {
roProcessedAttrs.normal.transform = transform
}
allProcessedAttrs.normal = roProcessedAttrs.normal
}
protected findAttrs(
cellAttrs: Attr.CellAttrs,
rootNode: Element,
selectorCache: { [selector: string]: Element[] },
selectors: Markup.Selectors,
) {
const merge: Element[] = []
const result: Dictionary<
Element,
{
elem: Element
array: boolean
priority: number | number[]
attrs: Attr.ComplexAttrs | Attr.ComplexAttrs[]
}
> = new Dictionary()
Object.keys(cellAttrs).forEach((selector) => {
const attrs = cellAttrs[selector]
if (!ObjectExt.isPlainObject(attrs)) {
return
}
const { isCSSSelector, elems } = View.find(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 Attr.ComplexAttrs]
prev.priority = [prev.priority as number]
}
const attributes = prev.attrs as Attr.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 Attr.ComplexAttrs[]
item.attrs = arr.reduceRight(
(memo, attrs) => ObjectExt.merge(memo, attrs),
{},
)
})
return result as Dictionary<
Element,
{
elem: Element
array: boolean
priority: number | number[]
attrs: Attr.ComplexAttrs
}
>
}
protected updateRelativeAttrs(
elem: Element,
processedAttrs: AttrManager.ProcessedAttrs,
refBBox: Rectangle,
options: AttrManager.UpdateOptions,
) {
const rawAttrs = processedAttrs.raw || {}
let nodeAttrs = processedAttrs.normal || {}
const setAttrs = processedAttrs.set
const positionAttrs = processedAttrs.position
const offsetAttrs = processedAttrs.offset
const delayAttrs = processedAttrs.delay
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 Attr.SetDefinition).set,
this.view,
val,
getOptions(),
)
if (typeof ret === 'object') {
nodeAttrs = {
...nodeAttrs,
...ret,
}
} else if (ret != null) {
nodeAttrs[name] = ret
}
}
})
}
if (Dom.isHTMLElement(elem)) {
// 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
}
// Calculates node scale determined by the scalable group.
let sx = 1
let sy = 1
if (positionAttrs || offsetAttrs) {
const scale = this.view.getScaleOfElement(
elem,
options.scalableNode as SVGElement,
)
sx = scale.sx
sy = scale.sy
}
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 Attr.PositionDefinition).position,
this.view,
val,
getOptions(),
)
if (ts != null) {
positioned = true
nodePosition.translate(Point.create(ts).scale(sx, sy))
}
}
})
}
// 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 = Dom.transformRectangle(
nodeBoundingRect,
nodeMatrix,
).scale(1 / sx, 1 / sy)
Object.keys(offsetAttrs).forEach((name) => {
const val = offsetAttrs[name]
const def = this.getDefinition(name)
if (def != null) {
const ts = FunctionExt.call(
(def as Attr.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).scale(sx, sy))
}
}
})
}
}
if (nodeTransform != null || positioned || offseted) {
nodePosition.round(1)
nodeMatrix.e = nodePosition.x
nodeMatrix.f = nodePosition.y
elem.setAttribute('transform', Dom.matrixToTransformString(nodeMatrix))
}
// delay render
const updateDelayAttrs = () => {
if (delayAttrs != null) {
Object.keys(delayAttrs).forEach((name) => {
const val = delayAttrs[name]
const def = this.getDefinition(name)
if (def != null) {
const ret = FunctionExt.call(
(def as Attr.SetDefinition).set,
this.view,
val,
getOptions(),
)
if (typeof ret === 'object') {
this.view.setAttrs(ret, elem)
} else if (ret != null) {
this.view.setAttrs(
{
[name]: ret,
},
elem,
)
}
}
})
}
}
if (options.forceSync) {
updateDelayAttrs()
} else {
Scheduler.scheduleTask(updateDelayAttrs)
}
}
update(
rootNode: Element,
attrs: Attr.CellAttrs,
options: AttrManager.UpdateOptions,
) {
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: Attr.ComplexAttrs | null
processedAttributes: AttrManager.ProcessedAttrs
}[] = []
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 &&
processed.delay == 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
? Dom.getBBox(refNode as SVGElement, { target })
: options.rootBBox
if (refNode) {
bboxCache.set(refNode, unrotatedRefBBox!)
}
}
let processedAttrs
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 = Dom.transformRectangle(unrotatedRefBBox!, rotatableMatrix)
}
const caller = specialItems.find((item) => item.refNode === node)
if (caller) {
options.forceSync = true
}
this.updateRelativeAttrs(node, processedAttrs, refBBox, options)
})
}
}
export namespace AttrManager {
export interface UpdateOptions {
rootBBox: Rectangle
selectors: Markup.Selectors
scalableNode?: Element | null
rotatableNode?: Element | null
/**
* Rendering only the specified attributes.
*/
attrs?: Attr.CellAttrs | null
/**
* Whether to force synchronous rendering
*/
forceSync?: boolean
}
export interface ProcessedAttrs {
raw: Attr.ComplexAttrs
normal?: Attr.SimpleAttrs | undefined
set?: Attr.ComplexAttrs | undefined
offset?: Attr.ComplexAttrs | undefined
position?: Attr.ComplexAttrs | undefined
delay?: Attr.ComplexAttrs | undefined
}
export const CASE_SENSITIVE_ATTR = ['viewBox']
export const DELAY_ATTRS = [
'text',
'textWrap',
'sourceMarker',
'targetMarker',
]
}