diff --git a/packages/x6/src/research/core/base-view.ts b/packages/x6/src/research/core/base-view.ts index d4f36b2d9f8..73ad3e5589d 100644 --- a/packages/x6/src/research/core/base-view.ts +++ b/packages/x6/src/research/core/base-view.ts @@ -2,7 +2,8 @@ import jQuery from 'jquery' import { KeyValue } from '../../types' import { globals } from './globals' import { Basecoat } from '../../entity' -import { v, Attributes } from '../../v' +import { v } from '../../v' +import { Attribute } from '../attr' export abstract class BaseView extends Basecoat { public readonly cid: string @@ -14,8 +15,8 @@ export abstract class BaseView extends Basecoat { } // tslint:disable-next-line - protected $(elem: Element | Document | JQuery) { - return jQuery(elem) + $(elem: any) { + return BaseView.$(elem) } protected getEventNamespace() { @@ -162,7 +163,26 @@ export namespace BaseView { } export namespace BaseView { - export interface JSONElement { + // tslint:disable-next-line + export function $(elem: any) { + return jQuery(elem) + } + + export function createElement(tagName?: string, isSvgElement?: boolean) { + return isSvgElement + ? v.createSvgElement(tagName || 'g') + : (v.createElementNS(tagName || 'div') as HTMLElement) + } +} + +export namespace BaseView { + export interface JsonMarkup { + /** + * The namespace URI of the element. It defaults to SVG namespace + * `"http://www.w3.org/2000/svg"`. + */ + ns?: string + /** * The type of element to be created. */ @@ -181,31 +201,21 @@ export namespace BaseView { */ groupSelector?: string | string[] - /** - * The namespace URI of the element. It defaults to SVG namespace - * `"http://www.w3.org/2000/svg"`. - */ - ns?: string - - attrs?: Attributes + attrs?: Attribute.SimpleAttributes style?: { [name: string]: string } className?: string | string[] - children?: JSONElement[] + children?: JsonMarkup[] textContent?: string } - export function createElement(tagName: string, isSvgElement: boolean) { - return isSvgElement - ? v.createSvgElement(tagName || 'g') - : (v.createElementNS(tagName || 'div') as HTMLElement) - } + export type Markup = string | JsonMarkup | JsonMarkup[] - export function parseDOMJSON( - json: JSONElement[], + export function parseJsonMarkup( + markup: JsonMarkup | JsonMarkup[], options: { ns?: string bare?: boolean @@ -215,21 +225,31 @@ export namespace BaseView { }, ) { const fragment = document.createDocumentFragment() - const selectors: { [key: string]: Element | Element[] } = {} - const groups: { [key: string]: Element[] } = {} - const queue = [json, fragment, options.ns] + const selectors: { [selector: string]: Element | Element[] } = {} + const groups: { [selector: string]: Element[] } = {} + const queue: { + markup: JsonMarkup[] + parentNode: Element | DocumentFragment + ns?: string + }[] = [ + { + markup: Array.isArray(markup) ? markup : [markup], + parentNode: fragment, + ns: options.ns, + }, + ] while (queue.length > 0) { - let ns = queue.pop() as string - const parentNode = (queue.pop() as any) as Element - const input = queue.pop() as JSONElement[] - const defines = Array.isArray(input) ? input : [input] + const item = queue.pop()! + let ns = item.ns + const defines = item.markup + const parentNode = item.parentNode defines.forEach(define => { // tagName const tagName = define.tagName if (!tagName) { - throw new Error('Invalid tagName') + throw new TypeError('Invalid tagName') } // ns @@ -237,8 +257,10 @@ export namespace BaseView { ns = define.ns } - const node = v.createElementNS(tagName, ns) const svg = ns === v.ns.svg + const node = ns + ? v.createElementNS(tagName, ns) + : v.createElement(tagName) // attrs const attrs = define.attrs @@ -246,7 +268,7 @@ export namespace BaseView { if (svg) { v.attr(node, attrs) } else { - jQuery(node).attr(attrs) + $(node).attr(attrs) } } @@ -259,8 +281,10 @@ export namespace BaseView { // classname const className = define.className if (className != null) { - const cls = Array.isArray(className) ? className.join(' ') : className - node.setAttribute('class', cls) + node.setAttribute( + 'class', + Array.isArray(className) ? className.join(' ') : className, + ) } // textContent @@ -309,7 +333,7 @@ export namespace BaseView { // children const children = define.children if (Array.isArray(children)) { - queue.push(children, node as any, ns) + queue.push({ ns, markup: children, parentNode: node }) } }) } @@ -320,6 +344,10 @@ export namespace BaseView { groups, } } + + export function renderStringMarkup(container: Element, markup: string) { + return v.batch(markup) + } } namespace Private { diff --git a/packages/x6/src/research/core/cell-view.ts b/packages/x6/src/research/core/cell-view.ts index 5c1fe97254a..159ce0e1a34 100644 --- a/packages/x6/src/research/core/cell-view.ts +++ b/packages/x6/src/research/core/cell-view.ts @@ -1,40 +1,65 @@ -import { ObjectExt } from '../../util' -import { v, Attributes } from '../../v' +import kebabCase from 'lodash/kebabCase' +import sortedIndex from 'lodash/sortedIndex' +import isPlainObject from 'lodash/isPlainObject' +import { Dictionary } from '../../struct' +import { v } from '../../v' import { BaseView } from './base-view' import { config } from './config' import { Cell } from './cell' -import { Rectangle } from '../../geometry' +import { Attribute } from '../attr' +import { JSONObject } from '../../util' +import { Rectangle, Point, Ellipse, Polyline, Path, Line } from '../../geometry' + +export abstract class CellView extends BaseView { + protected readonly tagName: string + protected readonly isSvgElement: boolean + protected readonly rootSelector: string + public readonly UPDATE_PRIORITY: number + public readonly initFlag: string | string[] + public readonly presentationAttributes: CellView.PresentationAttributes + protected readonly events: BaseView.Events | null + protected readonly documentEvents: BaseView.Events | null + protected cache: Dictionary -export class CellView extends BaseView { - protected readonly tagName: 'g' - protected readonly isSvgElement: boolean = true - protected readonly events: BaseView.Events | null = null - protected readonly documentEvents: BaseView.Events | null = null + protected selectors: CellView.Selectors - public readonly presentationAttributes: CellView.PresentationAttributes = {} - public readonly initFlag: string | string[] - public readonly UPDATE_PRIORITY: number = 2 + public cell: C + public graph: any + public scalableNode: Element | null + public rotatableNode: Element | null - selector: 'root' - metrics: any + protected flags: { [label: string]: number } + protected _presentationAttributes: { [name: string]: number } // tslint:disable-line + protected options: any - paper: any - cell: C - rotatableNode: any - selectors: { [key: string]: Element | Element[] } + constructor(cell: C) { + super() - _flags: { [name: string]: number } // tslint:disable-line - _presentationAttributes: { [name: string]: number } // tslint:disable-line + this.cell = cell - options: any + const config = this.configure() + this.tagName = config.tagName || 'g' + this.isSvgElement = config.isSvgElement !== false + this.rootSelector = config.rootSelector || 'root' + this.UPDATE_PRIORITY = + config.UPDATE_PRIORITY != null ? config.UPDATE_PRIORITY : 2 + this.initFlag = config.initFlag || [] + this.presentationAttributes = config.presentationAttributes || {} + this.events = config.events || null + this.documentEvents = config.documentEvents || null - constructor() { - super() - CellView.views[this.cid] = this this.setContainer(this.ensureContainer()) + this.initFlags() + this.cleanCache() + this.startListening() this.init() + this.$(this.container).data('view', this) + + CellView.views[this.cid] = this } + protected abstract configure(): CellView.Config + protected ensureContainer() { return BaseView.createElement(this.tagName, this.isSvgElement) } @@ -48,19 +73,20 @@ export class CellView extends BaseView { return this } - protected init() { - this.setFlags() - this.cleanNodesCache() - this.$(this.container).data('view', this) - this.startListening() - } + protected init() {} render() { return this } + empty() { + v.empty(this.container) + return this + } + unmount() { v.remove(this.container) + return this } remove() { @@ -70,11 +96,11 @@ export class CellView extends BaseView { return this } - protected renderChildren(children: BaseView.JSONElement[]) { + protected renderChildren(children: BaseView.JsonMarkup[]) { if (children) { const isSVG = this.container instanceof SVGElement const ns = isSVG ? v.ns.svg : v.ns.xhtml - const doc = BaseView.parseDOMJSON(children, { ns }) + const doc = BaseView.parseJsonMarkup(children, { ns }) v.empty(this.container) v.append(this.container, doc.fragment) // this.childNodes = doc.selectors @@ -82,32 +108,40 @@ export class CellView extends BaseView { return this } - addClass(className: string | string[]) { - v.addClass( - this.container, + addClass(className: string | string[], elem: Element = this.container) { + this.$(elem).addClass( Array.isArray(className) ? className.join(' ') : className, ) return this } - removeClass(className: string | string[]) { - v.removeClass( - this.container, + removeClass(className: string | string[], elem: Element = this.container) { + this.$(elem).removeClass( Array.isArray(className) ? className.join(' ') : className, ) return this } - setStyle(style: any) { - this.$(this.container).css(style) + setStyle( + style: JQuery.PlainObject, + elem: Element = this.container, + ) { + this.$(elem).css(style) + return this } - setAttributes(attrs: Attributes) { - if (this.container instanceof SVGElement) { - v.attr(this.container, attrs) - } else { - this.$(this.container).attr(attrs) + setAttributes( + attrs?: Attribute.SimpleAttributes | null, + elem: Element = this.container, + ) { + if (attrs != null && elem != null) { + if (elem instanceof SVGElement) { + v.attr(elem, attrs) + } else { + this.$(elem).attr(attrs) + } } + return this } /** @@ -140,10 +174,9 @@ export class CellView extends BaseView { return 0 } - // initFlags - setFlags() { + initFlags() { const flags: { [key: string]: number } = {} - const attributes: { [key: string]: number } = {} + const attrs: { [key: string]: number } = {} let shift = 0 Object.keys(this.presentationAttributes).forEach(name => { @@ -158,7 +191,7 @@ export class CellView extends BaseView { shift += 1 flag = flags[label] = 1 << shift } - attributes[name] |= flag + attrs[name] |= flag }) }) @@ -180,8 +213,8 @@ export class CellView extends BaseView { throw new Error('Maximum number of flags exceeded.') } - this._flags = flags - this._presentationAttributes = attributes + this.flags = flags + this._presentationAttributes = attrs } hasFlag(flag: number, label: string | string[]) { @@ -193,7 +226,7 @@ export class CellView extends BaseView { } getFlag(label: string | string[]) { - const flags = this._flags + const flags = this.flags if (flags == null) { return 0 } @@ -217,37 +250,40 @@ export class CellView extends BaseView { this.cell.on('change', this.onAttributesChange, this) } - onAttributesChange(cell: C, opt: any) { + onAttributesChange(cell: C, options: any) { let flag = cell.getChangeFlag(this._presentationAttributes) - if (opt.updateHandled || !flag) { + if (options.updated || !flag) { return } - if (opt.dirty && this.hasFlag(flag, 'UPDATE')) { - flag |= this.getFlag('RENDER') + if (options.dirty && this.hasFlag(flag, CellView.Flag.update)) { + flag |= this.getFlag(CellView.Flag.render) } // TODO: tool changes does not need to be sync // Fix Segments tools - if (opt.tool) { - opt.async = false + if (options.tool) { + options.async = false } - if (this.paper != null) { - this.paper.requestViewUpdate(this, flag, this.UPDATE_PRIORITY, opt) + if (this.graph != null) { + this.graph.requestViewUpdate(this, flag, this.UPDATE_PRIORITY, options) } } - parseDOMJSON(markup: BaseView.JSONElement[], root?: Element) { - const doc = BaseView.parseDOMJSON(markup) + parseDOMJSON( + markup: BaseView.JsonMarkup | BaseView.JsonMarkup[], + rootElem?: Element, + ) { + const doc = BaseView.parseJsonMarkup(markup) const selectors = doc.selectors - if (root) { - const rootSelector = this.selector + if (rootElem) { + const rootSelector = this.rootSelector if (selectors[rootSelector]) { throw new Error('Invalid root selector') } - selectors[rootSelector] = root + selectors[rootSelector] = rootElem } return { selectors, fragment: doc.fragment } @@ -275,73 +311,105 @@ export class CellView extends BaseView { this.options.interactive = value } - findBySelector( - selector?: string, - root: Element = this.container, - selectors: { [key: string]: Element | Element[] } = this.selectors, - ) { - // These are either descendants of `this.$el` of `this.$el` itself. - // `.` is a special selector used to select the wrapping `` element. - if (!selector || selector === '.') { - return [root] + notify(eventName: string, ...args: any[]) {} + + protected cleanCache() { + if (this.cache) { + this.cache.dispose() } + this.cache = new Dictionary() + } - if (selectors) { - const nodes = selectors[selector] - if (nodes) { - if (Array.isArray(nodes)) { - return nodes - } + protected getCache(elem: Element) { + const cache = this.cache + if (!cache.has(elem)) { + this.cache.set(elem, {}) + } + return this.cache.get(elem)! + } - return [nodes] - } + getNodeData(elem: Element) { + const meta = this.getCache(elem) + if (!meta.data) { + meta.data = {} } + return meta.data + } - if (config.useCSSSelectors) { - return this.$(root) - .find(selector) - .toArray() + getNodeBoundingRect(elem: Element) { + const meta = this.getCache(elem) + if (meta.boundingRect == null) { + meta.boundingRect = v.getBBox(elem as SVGElement) } + return meta.boundingRect.clone() + } - return [] + getNodeMatrix(elem: Element) { + const meta = this.getCache(elem) + if (meta.matrix == null) { + const target = this.rotatableNode || this.container + meta.matrix = v.getTransformToElement(elem as any, target as SVGElement) + } + + return v.createSVGMatrix(meta.matrix) } - notify(eventName: string, ...args: any[]) {} + getNodeShape(elem: SVGElement) { + const meta = this.getCache(elem) + if (meta.shape == null) { + meta.shape = v.toGeometryShape(elem) + } + return meta.shape.clone() + } + + getNodeScale(node: Element, scalableNode?: SVGElement) { + let sx + let sy + if (scalableNode && scalableNode.contains(node)) { + const scale = v.scale(scalableNode) + sx = 1 / scale.sx + sy = 1 / scale.sy + } else { + sx = 1 + sy = 1 + } - getBBox(opt: any) { + return { sx, sy } + } + + getBBox(options: { useModelGeometry?: boolean } = {}) { let bbox - if (opt && opt.useModelGeometry) { + if (options.useModelGeometry) { const cell = this.cell bbox = cell.getBBox().bbox((cell as any).rotation || 0) } else { bbox = this.getNodeBBox(this.container) } - return this.paper.localToPaperRect(bbox) + return this.graph.localToPaperRect(bbox) } - getNodeBBox(magnet: Element) { - const rect = this.getNodeBoundingRect(magnet) - const magnetMatrix = this.getNodeMatrix(magnet) + getNodeBBox(elem: Element) { + const rect = this.getNodeBoundingRect(elem) + const matrix = this.getNodeMatrix(elem) const translateMatrix = this.getRootTranslateMatrix() const rotateMatrix = this.getRootRotateMatrix() return v.transformRect( rect, - translateMatrix.multiply(rotateMatrix).multiply(magnetMatrix), + translateMatrix.multiply(rotateMatrix).multiply(matrix), ) } - getNodeUnrotatedBBox(magnet: SVGElement) { - const rect = this.getNodeBoundingRect(magnet) - const magnetMatrix = this.getNodeMatrix(magnet) + getNodeUnrotatedBBox(elem: SVGElement) { + const rect = this.getNodeBoundingRect(elem) + const matrix = this.getNodeMatrix(elem) const translateMatrix = this.getRootTranslateMatrix() - return v.transformRect(rect, translateMatrix.multiply(magnetMatrix)) + return v.transformRect(rect, translateMatrix.multiply(matrix)) } getRootTranslateMatrix() { - const position = (this.cell as any).position - const mt = v.createSVGMatrix().translate(position.x, position.y) - return mt + const pos = (this.cell as any).position + return v.createSVGMatrix().translate(pos.x, pos.y) } getRootRotateMatrix() { @@ -360,27 +428,61 @@ export class CellView extends BaseView { return matrix } - findMagnet(elem: Element) { - let $elem = this.$(elem) - const $root = this.$(this.container) - - if ($elem.length === 0) { - $elem = $root + find( + selector?: string, + rootElem: Element = this.container, + selectors: CellView.Selectors = this.selectors, + ) { + // These are either descendants of `this.$el` of `this.$el` itself. + // `.` is a special selector used to select the wrapping `` element. + if (!selector || selector === '.') { + return [rootElem] } - do { - const magnet = $elem.attr('magnet') - if ((magnet || $elem.is($root as any)) && magnet !== 'false') { - return $elem[0] + if (selectors) { + const nodes = selectors[selector] + if (nodes) { + if (Array.isArray(nodes)) { + return nodes + } + + return [nodes] } + } + + if (config.useCSSSelector) { + return this.$(rootElem) + .find(selector) + .toArray() as Element[] + } + + return [] + } - $elem = $elem.parent() - } while ($elem.length > 0) + findOne( + selector?: string, + rootElem: Element = this.container, + selectors: CellView.Selectors = this.selectors, + ) { + const nodes = this.find(selector, rootElem, selectors) + return nodes.length > 0 ? nodes[0] : null + } - // If the overall cell has set `magnet === false`, then return `undefined` to - // announce there is no magnet found for this cell. - // This is especially useful to set on cells that have 'ports'. In this case, - // only the ports have set `magnet === true` and the overall element has `magnet === false`. + findMagnet(elem: Element = this.container) { + let node = elem + do { + const magnet = node.getAttribute('magnet') + if ((magnet != null || node === this.container) && magnet !== 'false') { + return node + } + node = node.parentNode as Element + } while (node) + + // If the overall cell has set `magnet === false`, then returns + // `undefined` to announce there is no magnet found for this cell. + // This is especially useful to set on cells that have 'ports'. + // In this case, only the ports have set `magnet === true` and the + // overall element has `magnet === false`. return undefined } @@ -395,8 +497,8 @@ export class CellView extends BaseView { } if (elem) { - const nthChild = v.index(elem) + 1 - selector = `${elem.tagName}:nth-child(${nthChild})` + const nth = v.index(elem) + 1 + selector = `${elem.tagName}:nth-child(${nth})` if (prevSelector) { selector += ` > ${prevSelector}` } @@ -407,71 +509,472 @@ export class CellView extends BaseView { return selector } - getAttributeDefinition(attrName: string) { - // return this.model.constructor.getAttributeDefinition(attrName) + protected getAttributeDefinition( + attrName: string, + ): Attribute.Definition | null { + return this.cell.getAttributeDefinition(attrName) } - setNodeAttributes(node: Element, attrs: Attributes) { - if (!ObjectExt.isEmpty(attrs)) { - if (node instanceof SVGElement) { - v.attr(node, attrs) + protected processNodeAttributes( + node: Element, + raw: Attribute.ComplexAttributes, + ): CellView.ProcessedAttributes { + let normal: Attribute.SimpleAttributes | undefined + let set: Attribute.ComplexAttributes | undefined + let offset: Attribute.ComplexAttributes | undefined + let position: Attribute.ComplexAttributes | undefined + + const specials: { name: string; definition: Attribute.Definition }[] = [] + + // divide the attributes between normal and special + Object.keys(raw).forEach(name => { + const val = raw[name] + const definition = this.getAttributeDefinition(name) + const isValid = Attribute.isValidDefinition(definition, val, { + node, + attrs: raw, + view: this, + }) + + if (definition && isValid) { + if (typeof definition === 'string') { + if (normal == null) { + normal = {} + } + normal[definition] = val as Attribute.SimpleAttributeValue + } else if (val !== null) { + specials.push({ name, definition }) + } } else { - $(node).attr(attrs) + if (normal == null) { + normal = {} + } + normal[kebabCase(name)] = val as Attribute.SimpleAttributeValue + } + }) + + specials.forEach(({ name, definition }) => { + const val = raw[name] + + const setDefine = definition as Attribute.SetDefinition + if (typeof setDefine.set === 'function') { + if (set == null) { + set = {} + } + set[name] = val + } + + const offsetDefine = definition as Attribute.OffsetDefinition + if (typeof offsetDefine.offset === 'function') { + if (offset == null) { + offset = {} + } + offset[name] = val + } + + const positionDefine = definition as Attribute.PositionDefinition + if (typeof positionDefine.position === 'function') { + if (position == null) { + position = {} + } + position[name] = val } + }) + + return { + raw, + normal, + set, + offset, + position, } } - cleanNodesCache() { - this.metrics = {} - } + protected mergeProcessedAttributes( + allProcessedAttrs: CellView.ProcessedAttributes, + roProcessedAttrs: CellView.ProcessedAttributes, + ) { + allProcessedAttrs.set = { + ...allProcessedAttrs.set, + ...roProcessedAttrs.set, + } + + allProcessedAttrs.position = { + ...allProcessedAttrs.position, + ...roProcessedAttrs.position, + } - nodeCache(magnet: Element) { - const metrics = this.metrics - // Don't use cache? It most likely a custom view with overridden update. - if (!metrics) { - return {} + allProcessedAttrs.offset = { + ...allProcessedAttrs.offset, + ...roProcessedAttrs.offset, } - // 可以使用 weakmap 代替 id:value - const id = v.ensureId(magnet as any) - let value = metrics[id] - if (!value) { - value = metrics[id] = {} + // Handle also the special transform property. + const transform = + allProcessedAttrs.normal && allProcessedAttrs.normal.transform + if (transform != null && roProcessedAttrs.normal) { + roProcessedAttrs.normal.transform = transform } - return value + allProcessedAttrs.normal = roProcessedAttrs.normal } - getNodeData(magnet: Element) { - const metrics = this.nodeCache(magnet) - if (!metrics.data) metrics.data = {} - return metrics.data + protected findNodesAttributes( + cellAttrs: Attribute.CellAttributes, + rootNode: Element, + selectorCache: { [selector: string]: Element[] }, + selectors: CellView.Selectors, + ) { + const merge: Element[] = [] + const result: Dictionary< + Element, + { + node: Element + array: boolean + length: number | number[] + attrs: Attribute.ComplexAttributes | Attribute.ComplexAttributes[] + } + > = new Dictionary() + + Object.keys(cellAttrs).forEach(selector => { + const attrs = cellAttrs[selector] + if (!isPlainObject(attrs)) { + return + } + + selectorCache[selector] = this.find(selector, rootNode, selectors) + + const nodes = selectorCache[selector] + for (let i = 0, l = nodes.length; i < l; i += 1) { + const node = nodes[i] + const unique = selectors && selectors[selector] === node + const prev = result.get(node) + if (prev) { + if (!prev.array) { + merge.push(node) + prev.array = true + prev.attrs = [prev.attrs as Attribute.ComplexAttributes] + prev.length = [prev.length as number] + } + + const attributes = prev.attrs as Attribute.ComplexAttributes[] + const selectedLength = prev.length as number[] + if (unique) { + // node referenced by `selector` + attributes.unshift(attrs) + selectedLength.unshift(-1) + } else { + // node referenced by `groupSelector` + const sortIndex = sortedIndex(selectedLength, l) + attributes.splice(sortIndex, 0, attrs) + selectedLength.splice(sortIndex, 0, l) + } + } else { + result.set(node, { + node, + attrs, + length: unique ? -1 : l, + array: false, + }) + } + } + }) + + merge.forEach(node => { + const item = result.get(node)! + const arr = item.attrs as Attribute.ComplexAttributes[] + item.attrs = arr.reduceRight( + (memo, attrs) => ({ + ...memo, + ...attrs, + }), + {}, + ) + }) + + return result as Dictionary< + Element, + { + node: Element + array: boolean + length: number | number[] + attrs: Attribute.ComplexAttributes + } + > } - getNodeBoundingRect(magnet: Element) { - const metrics = this.nodeCache(magnet) - if (metrics.boundingRect == null) { - metrics.boundingRect = v(magnet as any).getBBox() + protected updateRelativeAttributes( + node: Element, + processedAttrs: CellView.ProcessedAttributes, + refBBox: Rectangle, + options: CellView.UpdateDOMSubtreeAttributesOptions = {}, + ) { + const rawAttrs = processedAttrs.raw || {} + let nodeAttrs = processedAttrs.normal || {} + const setAttrs = processedAttrs.set + const positionAttrs = processedAttrs.position + const offsetAttrs = processedAttrs.offset + const getOptions = () => ({ + node, + view: this, + attrs: rawAttrs, + refBBox: refBBox.clone(), + }) + + if (setAttrs != null) { + Object.keys(setAttrs).forEach(name => { + const val = setAttrs[name] + const def = this.getAttributeDefinition(name) + if (def != null) { + const ret = (def as Attribute.SetDefinition).set(val, getOptions()) + if (typeof ret === 'object') { + nodeAttrs = { + ...nodeAttrs, + ...ret, + } + } else if (ret != null) { + nodeAttrs[name] = ret + } + } + }) + } + + if (node instanceof HTMLElement) { + // TODO: setting the `transform` attribute on HTMLElements + // via `node.style.transform = 'matrix(...)';` would introduce + // a breaking change (e.g. basic.TextBlock). + this.setAttributes(nodeAttrs, node) + return } - return new Rectangle(metrics.boundingRect) - } + // The final translation of the subelement. + const nodeTransform = nodeAttrs.transform + const transform = nodeTransform ? `${nodeTransform}` : null + const nodeMatrix = v.transformStringToMatrix(transform) + const nodePosition = new Point(nodeMatrix.e, nodeMatrix.f) + if (nodeTransform) { + delete nodeAttrs.transform + nodeMatrix.e = 0 + nodeMatrix.f = 0 + } - getNodeMatrix(magnet: Element) { - const metrics = this.nodeCache(magnet) - if (metrics.magnetMatrix === undefined) { - const target = this.rotatableNode || this.container - metrics.magnetMatrix = v.getTransformToElement(magnet as any, target) + // Calculates node scale determined by the scalable group. + let sx = 1 + let sy = 1 + if (positionAttrs || offsetAttrs) { + const scale = this.getNodeScale(node, 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.getAttributeDefinition(name) + if (def != null) { + const ts = (def as Attribute.PositionDefinition).position( + val, + getOptions(), + ) + + if (ts != null) { + positioned = true + nodePosition.translate(Point.create(ts).scale(sx, sy)) + } + } + }) } - return v.createSVGMatrix(metrics.magnetMatrix) + // The node bounding box could depend on the `size` + // set from the previous loop. + this.setAttributes(nodeAttrs, node) + + let offseted = false + if (offsetAttrs != null) { + // Check if the node is visible + const nodeBoundingRect = this.getNodeBoundingRect(node) + if (nodeBoundingRect.width > 0 && nodeBoundingRect.height > 0) { + const nodeBBox = v + .transformRect(nodeBoundingRect, nodeMatrix) + .scale(1 / sx, 1 / sy) + + Object.keys(offsetAttrs).forEach(name => { + const val = offsetAttrs[name] + const def = this.getAttributeDefinition(name) + if (def != null) { + const ts = (def as Attribute.OffsetDefinition).offset(val, { + node, + view: this, + 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 + node.setAttribute('transform', v.matrixToTransformString(nodeMatrix)) + } } - getNodeShape(magnet: SVGElement) { - const metrics = this.nodeCache(magnet) - if (metrics.geometryShape === undefined) { - metrics.geometryShape = v.toGeometryShape(magnet) + protected updateDOMSubtreeAttributes( + rootNode: Element, + attrs: Attribute.CellAttributes, + options: CellView.UpdateDOMSubtreeAttributesOptions = {}, + ) { + if (options.rootBBox == null) { + options.rootBBox = new Rectangle() + } + + if (options.selectors == null) { + options.selectors = this.selectors } - return metrics.geometryShape.clone() + + const selectorCache: { [selector: string]: Element[] } = {} + const nodesAttrs = this.findNodesAttributes( + 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.findNodesAttributes( + attrs, + rootNode, + selectorCache, + options.selectors, + ) + : nodesAttrs + + const specialItems: { + node: Element + refNode: Element | null + attributes: Attribute.ComplexAttributes | null + processedAttributes: CellView.ProcessedAttributes + }[] = [] + + nodesAttrs.each(data => { + const node = data.node + const nodeAttrs = data.attrs + const processed = this.processNodeAttributes(node, nodeAttrs) + if ( + processed.set == null && + processed.position == null && + processed.offset == null + ) { + this.setAttributes(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.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 = 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 && + v.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 + ? v.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.processNodeAttributes(node, item.attributes) + this.mergeProcessedAttributes(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 = v.transformStringToMatrix( + v.attr(options.rotatableNode, 'transform'), + ) + } + refBBox = v.transformRect(unrotatedRefBBox!, rotatableMatrix) + } + + this.updateRelativeAttributes(node, processedAttrs, refBBox, options) + }) } getEventTarget( @@ -556,7 +1059,7 @@ export class CellView extends BaseView { magnetcontextmenu() {} checkMouseleave(evt: JQuery.MouseLeaveEvent) { - const paper = this.paper + const paper = this.graph if (paper.isAsync()) { // Do the updates of the current view synchronously now paper.dumpView(this) @@ -573,16 +1076,64 @@ export class CellView extends BaseView { } export namespace CellView { + export type Selectors = { [selector: string]: Element | Element[] } + + export enum Flag { + render = 'render', + update = 'update', + resize = 'resize', + rotate = 'rotate', + translate = 'translate', + ports = 'ports', + tools = 'tools', + } + export interface ProcessedAttributes { - set?: Attributes - position?: Attributes - offset?: Attributes - normal?: Attributes + raw: Attribute.ComplexAttributes + normal?: Attribute.SimpleAttributes | undefined + set?: Attribute.ComplexAttributes | undefined + offset?: Attribute.ComplexAttributes | undefined + position?: Attribute.ComplexAttributes | undefined + } + + export interface UpdateDOMSubtreeAttributesOptions { + rootBBox?: Rectangle + selectors?: CellView.Selectors + scalableNode?: Element | null + rotatableNode?: Element | null + /** + * Rendering only attributes. + */ + attrs?: Attribute.CellAttributes | null } export interface PresentationAttributes { - [name: string]: string | string[] + [attributeName: string]: string | string[] } + export interface Config { + tagName?: string + isSvgElement?: boolean + rootSelector?: string + UPDATE_PRIORITY?: number + initFlag?: string | string[] + presentationAttributes?: PresentationAttributes + events?: BaseView.Events + documentEvents?: BaseView.Events + } + + export interface CacheItem { + data?: JSONObject + matrix?: DOMMatrix + boundingRect?: Rectangle + shape?: Rectangle | Ellipse | Polyline | Path | Line + } +} + +export namespace CellView { export const views: { [cid: string]: CellView } = {} + + export function getView(cid: string) { + return views[cid] || null + } } diff --git a/packages/x6/src/research/core/cell.ts b/packages/x6/src/research/core/cell.ts index 4cbc5750744..801f2702cd1 100644 --- a/packages/x6/src/research/core/cell.ts +++ b/packages/x6/src/research/core/cell.ts @@ -1,19 +1,19 @@ /* tslint:disable:variable-name */ import { JSONObject, JSONExt, ArrayExt, StringExt } from '../../util' -import { KeyValue } from '../../types' import { Basecoat } from '../../entity' import { Rectangle } from '../../geometry' import { Store } from './store' import { Model } from './model' import { Node } from './node' +import { Attribute } from '../attr' +import { KeyValue } from '../../types' export class Cell extends Basecoat { public readonly id: string public readonly store: Store public model: Model | null - private _parent: Cell | null private _children: Cell[] | null @@ -26,6 +26,7 @@ export class Cell extends Basecoat { id: this.id, }) this.startListening() + this.init() } protected startListening() { @@ -43,6 +44,8 @@ export class Cell extends Basecoat { }) } + protected init() {} + isNode(): this is Node { return false } @@ -51,9 +54,33 @@ export class Cell extends Basecoat { return false } - toJSON() {} + // #region - clone() {} + getPropByPath(path: string | string[]) { + return this.store.getByPath(path) + } + + setPropByPath( + path: string | string[], + value: any, + options: Cell.SetPropByPathOptions = {}, + ) { + this.store.setByPath(path, value, options) + } + + removePropByPath(path: string | string[], options: Cell.SetOptions = {}) { + const paths = Array.isArray(path) ? path : path.split('/') + // Once a property is removed from the `attrs` the CellView will + // recognize a `dirty` flag and re-render itself in order to remove + // the attribute from SVGElement. + if (paths[0] === 'attrs') { + options.dirty = true + } + this.store.removeByPath(paths, options) + return this + } + + // #endregion // #region zIndex @@ -93,19 +120,50 @@ export class Cell extends Basecoat { // #region attrs - get arrts() { - const result = this.store.get('attrs') - return result ? JSONExt.deepCopy(result) : result + get attrs() { + const result = this.store.get('attrs') + return result ? JSONExt.deepCopy(result) : {} } - set attrs(value: JSONObject) { + set attrs(value: Attribute.CellAttributes) { this.setAttrs(value) } - setAttrs(attrs: JSONObject, options: Cell.SetOptions = {}) { + setAttrs(attrs: Attribute.CellAttributes, options: Cell.SetOptions = {}) { this.store.set('attrs', attrs, options) } + getAttributeDefinition(attrName: string) { + return Attribute.definitions[attrName] || null + } + + getAttrByPath(): Attribute.CellAttributes + getAttrByPath(path: string | string[]): T + getAttrByPath(path?: string | string[]) { + if (path == null || path === '') { + return this.attrs + } + return this.getPropByPath(this.prependAttrsPath(path)) + } + + setAttrByPath( + path: string | string[], + value: Attribute.ComplexAttributeValue, + options: Cell.SetOptions = {}, + ) { + this.setPropByPath(this.prependAttrsPath(path), value, options) + return this + } + + removeAttrByPath(path: string | string[], options: Cell.SetOptions = {}) { + this.removePropByPath(this.prependAttrsPath(path), options) + return this + } + + protected prependAttrsPath(path: string | string[]) { + return Array.isArray(path) ? ['attrs'].concat(path) : `attrs/${path}` + } + // #endregion // #region visible @@ -379,6 +437,10 @@ export class Cell extends Basecoat { // #endregion + toJSON() {} + + clone() {} + addTo() {} findView() {} @@ -435,6 +497,8 @@ export class Cell extends Basecoat { export namespace Cell { export interface SetOptions extends Store.SetOptions {} + export interface SetPropByPathOptions extends Store.SetByPathOptions {} + export interface CreateCellOptions extends JSONObject { id?: string markup?: string diff --git a/packages/x6/src/research/core/config.ts b/packages/x6/src/research/core/config.ts index de13382af21..a88052d5a1c 100644 --- a/packages/x6/src/research/core/config.ts +++ b/packages/x6/src/research/core/config.ts @@ -2,7 +2,7 @@ export const config = { // When set to `true` the cell selectors could be defined as CSS selectors. // If not, only JSON Markup selectors are taken into account. // export let useCSSSelectors = true; - useCSSSelectors: true, + useCSSSelector: true, // The class name prefix config is for advanced use only. // Be aware that if you change the prefix, the JointJS CSS will no longer function properly. // export let classNamePrefix = 'joint-'; diff --git a/packages/x6/src/research/core/edge.ts b/packages/x6/src/research/core/edge.ts index 33b2dad56c2..a62d3078020 100644 --- a/packages/x6/src/research/core/edge.ts +++ b/packages/x6/src/research/core/edge.ts @@ -33,12 +33,12 @@ export class Edge extends Cell { } disconnect(options: Cell.SetOptions) { - return this.store.set( - { - source: { x: 0, y: 0 }, - target: { x: 0, y: 0 }, - }, - options, - ) + // return this.store.set( + // { + // source: { x: 0, y: 0 }, + // target: { x: 0, y: 0 }, + // }, + // options, + // ) } } diff --git a/packages/x6/src/research/core/graph.ts b/packages/x6/src/research/core/graph.ts index 62cf3dab26d..b3e854369e6 100644 --- a/packages/x6/src/research/core/graph.ts +++ b/packages/x6/src/research/core/graph.ts @@ -239,7 +239,7 @@ export class Graph extends BaseView { constructor(options: Graph.Options) { super() this.container = options.container - const { selectors, fragment } = Graph.parseDOMJSON(Graph.markup, { + const { selectors, fragment } = Graph.parseJsonMarkup(Graph.markup, { bare: true, }) this.backgroundElem = selectors.background as HTMLDivElement @@ -250,6 +250,7 @@ export class Graph extends BaseView { this.drawPane = selectors.drawPane as SVGGElement this.container.appendChild(fragment) this.model = new Model() + this.resetUpdates() this.startListening() } @@ -285,7 +286,7 @@ export class Graph extends BaseView { protected resetUpdates() { return (this.updates = { - id: null, + id: null, // animation frame id priorities: [{}, {}, {}], mounted: {}, @@ -309,6 +310,7 @@ export class Graph extends BaseView { console.log('cell added') this.onCellAdded(cell as any, options) }) + // collection.on('remove', this.onCellRemoved, this) // collection.on('reset', this.onGraphReset, this) collection.on('sort', () => this.onGraphSort) @@ -361,7 +363,10 @@ export class Graph extends BaseView { // } onGraphSort() { - if (this.model.hasActiveBatch(this.SORT_DELAYING_BATCHES)) return + if (this.model.hasActiveBatch(this.SORT_DELAYING_BATCHES)) { + return + } + this.sortViews() } @@ -371,13 +376,13 @@ export class Graph extends BaseView { } const name = data && data.batchName - const graph = this.model + const model = this.model if (!this.isAsync()) { // UPDATE_DELAYING_BATCHES: ['translate'], const updateDelayingBatches = this.UPDATE_DELAYING_BATCHES if ( updateDelayingBatches.includes(name) && - !graph.hasActiveBatch(updateDelayingBatches) + !model.hasActiveBatch(updateDelayingBatches) ) { this.updateViews(data) } @@ -387,7 +392,7 @@ export class Graph extends BaseView { const sortDelayingBatches = this.SORT_DELAYING_BATCHES if ( sortDelayingBatches.includes(name) && - !graph.hasActiveBatch(sortDelayingBatches) + !model.hasActiveBatch(sortDelayingBatches) ) { this.sortViews() } @@ -448,8 +453,8 @@ export class Graph extends BaseView { return false } - const model = view.cell - if (model.isNode()) { + const cell = view.cell + if (cell.isNode()) { return false } @@ -538,19 +543,6 @@ export class Graph extends BaseView { } } - dumpViewUpdate(view: CellView) { - if (view == null) { - return 0 - } - - const id = view.cid - const updates = this.updates - const priorityUpdates = updates.priorities[view.UPDATE_PRIORITY] - const flag = this.registerMountedView(view) | priorityUpdates[id] - delete priorityUpdates[id] - return flag - } - updateView(view: CellView, flag: number, opt: any = {}) { if (view == null) { return 0 @@ -561,6 +553,7 @@ export class Graph extends BaseView { this.removeView(view.cell as any) return 0 } + if (flag & FLAG_INSERT) { this.insertView(view) flag ^= FLAG_INSERT // tslint:disable-line @@ -574,6 +567,22 @@ export class Graph extends BaseView { return view.confirmUpdate(flag, opt || {}) } + dumpViewUpdate(view: CellView) { + if (view == null) { + return 0 + } + + const cid = view.cid + const updates = this.updates + const priorityUpdates = updates.priorities[view.UPDATE_PRIORITY] + const flag = this.registerMountedView(view) | priorityUpdates[cid] + delete priorityUpdates[cid] + return flag + } + + /** + * Adds view into the DOM and update it. + */ dumpView(view: CellView, opt: any = {}) { const flag = this.dumpViewUpdate(view) if (!flag) { @@ -582,6 +591,14 @@ export class Graph extends BaseView { return this.updateView(view, flag, opt) } + /** + * Adds all views into the DOM and update them. + */ + dumpViews(opt: any = {}) { + this.checkViewport(opt) + this.updateViews(opt) + } + /** * Ensure the view associated with the cell is attached to the DOM and updated. */ @@ -594,53 +611,6 @@ export class Graph extends BaseView { return view } - registerMountedView(view: CellView) { - const cid = view.cid - const updates = this.updates - if (cid in updates.mounted) { - return 0 - } - - updates.mounted[cid] = true - updates.mountedCids.push(cid) - const flag = updates.unmounted[cid] || 0 - delete updates.unmounted[cid] - return flag - } - - registerUnmountedView(view: CellView) { - const cid = view.cid - const updates = this.updates - if (cid in updates.unmounted) { - return 0 - } - - updates.unmounted[cid] |= FLAG_INSERT - - const flag = updates.unmounted[cid] - updates.unmountedCids.push(cid) - delete updates.mounted[cid] - return flag - } - - isViewMounted(view: CellView) { - if (view == null) { - return false - } - const cid = view.cid - const updates = this.updates - return cid in updates.mounted - } - - /** - * Adds all views into the DOM and update them to make sure that the views - * reflect the cells. - */ - dumpViews(opt: any = {}) { - this.checkViewport(opt) - this.updateViews(opt) - } - /** * Updates views in a frozen async graph to make sure that the views reflect * the cells and keep the graph frozen. @@ -657,7 +627,11 @@ export class Graph extends BaseView { priority = Math.min(stats.priority, priority) } while (!stats.empty) - return { priority, updated: updateCount, batches: batchCount } + return { + priority, + updated: updateCount, + batches: batchCount, + } } updateViewsAsync( @@ -682,13 +656,12 @@ export class Graph extends BaseView { } const stats = this.updateViewsBatch(opt) - const passingOpt = { + const checkStats = this.checkViewport({ mountBatchSize: MOUNT_BATCH_SIZE - stats.mounted, unmountBatchSize: MOUNT_BATCH_SIZE - stats.unmounted, ...opt, - } + }) - const checkStats = this.checkViewport(passingOpt) const unmountCount = checkStats.unmounted const mountCount = checkStats.mounted let processed = data.processed @@ -709,13 +682,17 @@ export class Graph extends BaseView { data.processed = processed } } + // Progress callback const progressFn = opt.progress if (total && typeof progressFn === 'function') { progressFn.call(this, stats.empty, processed, total, stats, this) } + // The current frame could have been canceled in a callback - if (updates.id !== id) return + if (updates.id !== id) { + return + } } updates.id = DomUtil.requestAnimationFrame(() => { @@ -750,7 +727,7 @@ export class Graph extends BaseView { const cache = priorities[p] for (const cid in cache) { if (updateCount >= batchSize) { - empty = false + empty = false // 还未渲染完成 break main } @@ -799,7 +776,10 @@ export class Graph extends BaseView { } } - if (maxPriority > p) maxPriority = p + if (maxPriority > p) { + maxPriority = p + } + updateCount += 1 delete cache[cid] } @@ -815,6 +795,45 @@ export class Graph extends BaseView { } } + registerMountedView(view: CellView) { + const cid = view.cid + const updates = this.updates + if (cid in updates.mounted) { + return 0 + } + + updates.mounted[cid] = true + updates.mountedCids.push(cid) + const flag = updates.unmounted[cid] || 0 + delete updates.unmounted[cid] + return flag + } + + registerUnmountedView(view: CellView) { + const cid = view.cid + const updates = this.updates + if (cid in updates.unmounted) { + return 0 + } + + updates.unmounted[cid] |= FLAG_INSERT + + const flag = updates.unmounted[cid] + updates.unmountedCids.push(cid) + delete updates.mounted[cid] + return flag + } + + isViewMounted(view: CellView) { + if (view == null) { + return false + } + + const cid = view.cid + const updates = this.updates + return cid in updates.mounted + } + getUnmountedViews() { const updates = this.updates return Object.keys(updates.unmounted).map(id => CellView.views[id]) @@ -840,6 +859,7 @@ export class Graph extends BaseView { if (!(cid in unmounted)) { continue } + const view = CellView.views[cid] if (view == null) { continue @@ -900,6 +920,7 @@ export class Graph extends BaseView { view.unmount() } } + // Get rid of views, that have been unmounted mountedCids.splice(0, size) return unmountCount @@ -911,16 +932,19 @@ export class Graph extends BaseView { unmountBatchSize: Infinity, ...options, } + const viewportFn = opts.viewport || this.options.viewport const unmountedCount = this.checkMountedViews(viewportFn, opts) if (unmountedCount > 0) { - // Do not check views, that have been just unmounted and pushed at the end of the cids array + // Do not check views, that have been just unmounted + // and pushed at the end of the cids array const unmountedCids = this.updates.unmountedCids opts.mountBatchSize = Math.min( unmountedCids.length - unmountedCount, opts.mountBatchSize, ) } + const mountedCount = this.checkUnmountedViews(viewportFn, opts) return { mounted: mountedCount, @@ -1032,7 +1056,7 @@ export class Graph extends BaseView { // interactive: this.options.interactive, // }) - return new NodeView() + return new NodeView(cell as any) } removeView(cell: Cell) { @@ -1048,6 +1072,16 @@ export class Graph extends BaseView { return view } + removeViews() { + Object.keys(this.views).forEach(id => { + const view = this.views[id] + if (view) { + view.remove() + } + }) + this.views = {} + } + renderView(cell: Cell, options: any = {}) { const id = cell.id const views = this.views @@ -1077,16 +1111,6 @@ export class Graph extends BaseView { this.sortViews() } - removeViews() { - Object.keys(this.views).forEach(id => { - const view = this.views[id] - if (view) { - view.remove() - } - }) - this.views = {} - } - sortViews() { if (!this.isExactSorting()) { // noop @@ -1663,7 +1687,7 @@ export namespace Graph { } } export namespace Graph { - export const markup: BaseView.JSONElement[] = [ + export const markup: BaseView.JsonMarkup[] = [ { ns: v.ns.xhtml, tagName: 'div', diff --git a/packages/x6/src/research/core/node-view.ts b/packages/x6/src/research/core/node-view.ts index 3e6deca9204..fedef59e889 100644 --- a/packages/x6/src/research/core/node-view.ts +++ b/packages/x6/src/research/core/node-view.ts @@ -1,116 +1,148 @@ import { CellView } from './cell-view' +import { BaseView } from './base-view' import { config } from './config' import { v } from '../../v' import { Node } from './node' +import { Rectangle } from '../../geometry' +import { Attribute } from '../attr' +import { PortData } from './port-data' +import { ArrayExt } from '../../util' export class NodeView extends CellView { - presentationAttributes = { - attrs: ['UPDATE'], - position: ['TRANSLATE', 'TOOLS'], - size: ['RESIZE', 'PORTS', 'TOOLS'], - angle: ['ROTATE', 'TOOLS'], - markup: ['RENDER'], - ports: ['PORTS'], + protected readonly rotatableSelector: string = 'rotatable' + protected readonly scalableSelector: string = 'scalable' + public scalableNode: Element | null = null + public rotatableNode: Element | null = null + + protected portsCache: { [id: string]: NodeView.PortCache } = {} + protected portContainerMarkup: BaseView.Markup = 'g' + protected portMarkup: BaseView.Markup = { + tagName: 'circle', + selector: 'circle', + attrs: { + r: 10, + fill: '#FFFFFF', + stroke: '#000000', + }, + } + protected portLabelMarkup: BaseView.Markup = { + tagName: 'text', + selector: 'text', + attrs: { + fill: '#000000', + }, } - initFlag = ['RENDER'] - - UPDATE_PRIORITY = 0 - - rotatableSelector: string = 'rotatable' - scalableSelector: string = 'scalable' - scalableNode: Element | null = null - rotatableNode: Element | null = null + configure() { + return { + UPDATE_PRIORITY: 0, + initFlag: [NodeView.Flag.render], + presentationAttributes: { + markup: [NodeView.Flag.render], + attrs: [NodeView.Flag.update], + ports: [NodeView.Flag.ports], + size: [NodeView.Flag.resize, NodeView.Flag.ports, NodeView.Flag.tools], + rotation: [NodeView.Flag.rotate, NodeView.Flag.tools], + position: [NodeView.Flag.translate, NodeView.Flag.tools], + }, + } + } confirmUpdate(flag: number, options: any = {}) { let sub = flag - if (this.hasFlag(sub, 'PORTS')) { - // this._removePorts() - // this._cleanPortsCache() + if (this.hasFlag(sub, NodeView.Flag.ports)) { + this.removePorts() + this.cleanPortsCache() } - if (this.hasFlag(sub, 'RENDER')) { + if (this.hasFlag(sub, NodeView.Flag.render)) { this.render() // this.updateTools(opt) sub = this.removeFlag(sub, [ - 'RENDER', - 'UPDATE', - 'RESIZE', - 'TRANSLATE', - 'ROTATE', - 'PORTS', + NodeView.Flag.render, + NodeView.Flag.update, + NodeView.Flag.resize, + NodeView.Flag.translate, + NodeView.Flag.rotate, + NodeView.Flag.ports, ]) } else { // Skip this branch if render is required - if (this.hasFlag(sub, 'RESIZE')) { + if (this.hasFlag(sub, NodeView.Flag.resize)) { this.resize(options) // Resize method is calling `update()` internally - sub = this.removeFlag(sub, ['RESIZE', 'UPDATE']) + sub = this.removeFlag(sub, [NodeView.Flag.resize, NodeView.Flag.update]) } - if (this.hasFlag(sub, 'UPDATE')) { + if (this.hasFlag(sub, NodeView.Flag.update)) { this.update() - sub = this.removeFlag(sub, 'UPDATE') - if (config.useCSSSelectors) { + sub = this.removeFlag(sub, NodeView.Flag.update) + if (config.useCSSSelector) { // `update()` will render ports when useCSSSelectors are enabled - sub = this.removeFlag(sub, 'PORTS') + sub = this.removeFlag(sub, NodeView.Flag.ports) } } - if (this.hasFlag(sub, 'TRANSLATE')) { + if (this.hasFlag(sub, NodeView.Flag.translate)) { this.translate() - sub = this.removeFlag(sub, 'TRANSLATE') + sub = this.removeFlag(sub, NodeView.Flag.translate) } - if (this.hasFlag(sub, 'ROTATE')) { + if (this.hasFlag(sub, NodeView.Flag.rotate)) { this.rotate() - sub = this.removeFlag(sub, 'ROTATE') + sub = this.removeFlag(sub, NodeView.Flag.rotate) } - if (this.hasFlag(sub, 'PORTS')) { - // this._renderPorts() - sub = this.removeFlag(sub, 'PORTS') + if (this.hasFlag(sub, NodeView.Flag.ports)) { + this.renderPorts() + sub = this.removeFlag(sub, NodeView.Flag.ports) } } - if (this.hasFlag(sub, 'TOOLS')) { + if (this.hasFlag(sub, NodeView.Flag.tools)) { // this.updateTools(options) - sub = this.removeFlag(sub, 'TOOLS') + sub = this.removeFlag(sub, NodeView.Flag.tools) } return sub } - update() { - // update(_: any, renderingOnlyAttrs: Attributes) { - this.cleanNodesCache() + update(partialAttrs?: Attribute.CellAttributes) { + this.cleanCache() // When CSS selector strings are used, make sure no rule matches port nodes. - // const { useCSSSelectors } = config - // if (useCSSSelectors) this._removePorts() - - // const model = this.model - // const modelAttrs = model.attr() - // this.updateDOMSubtreeAttributes(this.el, modelAttrs, { - // rootBBox: new Rect(model.size()), - // selectors: this.selectors, - // scalableNode: this.scalableNode, - // rotatableNode: this.rotatableNode, - // // Use rendering only attributes if they differs from the model attributes - // roAttributes: - // renderingOnlyAttrs === modelAttrs ? null : renderingOnlyAttrs, - // }) + const { useCSSSelector: useCSSSelectors } = config + if (useCSSSelectors) { + this.removePorts() + } - // if (useCSSSelectors) this._renderPorts() + const node = this.cell + const size = node.size + const attrs = node.attrs + this.updateDOMSubtreeAttributes(this.container, attrs, { + attrs: partialAttrs === attrs ? null : partialAttrs, + rootBBox: new Rectangle(0, 0, size.width, size.height), + selectors: this.selectors, + scalableNode: this.scalableNode, + rotatableNode: this.rotatableNode, + }) + + if (useCSSSelectors) { + this.renderPorts() + } } protected renderMarkup() { - const element = this.cell - const markup = element.markup - if (!markup) throw new Error('dia.ElementView: markup required') - // if (Array.isArray(markup)) return this.renderJSONMarkup(markup) - if (typeof markup === 'string') return this.renderStringMarkup(markup) - throw new Error('dia.ElementView: invalid markup') + const cell = this.cell + const markup = cell.markup + if (markup) { + // if (Array.isArray(markup)) return this.renderJSONMarkup(markup) + if (typeof markup === 'string') { + return this.renderStringMarkup(markup) + } + } + + throw new TypeError('invalid markup') } // protected renderJSONMarkup(markup: CellView.JSONElement[]) { @@ -128,12 +160,13 @@ export class NodeView extends CellView { this.rotatableNode = v.findOne(this.container, '.rotatable') this.scalableNode = v.findOne(this.container, '.scalable') this.selectors = {} - this.selectors[this.selector] = this.container + this.selectors[this.rootSelector] = this.container } render() { - v.empty(this.container) + this.empty() this.renderMarkup() + if (this.scalableNode) { // Double update is necessary for elements with the scalable group only // Note the resize() triggers the other `update`. @@ -148,10 +181,10 @@ export class NodeView extends CellView { this.rotate() this.translate() } else { - this.updateTransformation() + this.updateTransform() } - if (!config.useCSSSelectors) { + if (!config.useCSSSelector) { // this._renderPorts() } @@ -160,7 +193,7 @@ export class NodeView extends CellView { resize(opt: any = {}) { if (this.scalableNode) { - return this.sgResize(opt) + return this.resizeScalableNode(opt) } if (this.cell.rotation) { @@ -175,28 +208,28 @@ export class NodeView extends CellView { return this.rgTranslate() } - this.updateTransformation() + this.updateTransform() } rotate() { if (this.rotatableNode) { - this.rgRotate() + this.rotateRotatableNode() // It's necessary to call the update for the nodes outside // the rotatable group referencing nodes inside the group this.update() return } - this.updateTransformation() + this.updateTransform() } - updateTransformation() { - let transformation = this.getTranslateString() - const rotateString = this.getRotateString() - if (rotateString) { - transformation += ` ${rotateString}` + updateTransform() { + let transform = this.getTranslateString() + const rot = this.getRotateString() + if (rot) { + transform += ` ${rot}` } - this.container.setAttribute('transform', transformation) + this.container.setAttribute('transform', transform) } getTranslateString() { @@ -205,45 +238,53 @@ export class NodeView extends CellView { } getRotateString() { - const angle = this.cell.rotation - if (!angle) { - return '' + const rotation = this.cell.rotation + if (rotation) { + const size = this.cell.size + return `rotate(${rotation},${size.width / 2},${size.height / 2})` } - - const size = this.cell.size - return `rotate(${angle},${size.width / 2},${size.height / 2})` } - rgRotate() { - this.rotatableNode?.setAttribute('transform', this.getRotateString()) + rotateRotatableNode() { + if (this.rotatableNode != null) { + const transform = this.getRotateString() + if (transform != null) { + this.rotatableNode.setAttribute('transform', transform) + } else { + this.rotatableNode.removeAttribute('transform') + } + } } rgTranslate() { this.container.setAttribute('transform', this.getTranslateString()) } - sgResize(opt: any = {}) { - const model = this.cell - const size = model.size - const angle = model.rotation - const scalable = this.scalableNode! + resizeScalableNode(opt: any = {}) { + const cell = this.cell + const size = cell.size + const angle = cell.rotation + const scalableNode = this.scalableNode! // Getting scalable group's bbox. - // Due to a bug in webkit's native SVG .getBBox implementation, the bbox of groups with path children includes the paths' control points. - // To work around the issue, we need to check whether there are any path elements inside the scalable group. + // Due to a bug in webkit's native SVG .getBBox implementation, the + // bbox of groups with path children includes the paths' control points. + // To work around the issue, we need to check whether there are any path + // elements inside the scalable group. let recursive = false - if (scalable.getElementsByTagName('path').length > 0) { - // If scalable has at least one descendant that is a path, we need to switch to recursive bbox calculation. - // If there are no path descendants, group bbox calculation works and so we can use the (faster) native function directly. + if (scalableNode.getElementsByTagName('path').length > 0) { + // If scalable has at least one descendant that is a path, we need + // toswitch to recursive bbox calculation. Otherwise, group bbox + // calculation works and so we can use the (faster) native function. recursive = true } - const scalableBBox = v.getBBox(scalable as any, { recursive }) + const scalableBBox = v.getBBox(scalableNode as SVGElement, { recursive }) - // Make sure `scalableBbox.width` and `scalableBbox.height` are not zero which can happen if the element does not have any content. By making - // the width/height 1, we prevent HTML errors of the type `scale(Infinity, Infinity)`. + // Make sure `scalableBbox.width` and `scalableBbox.height` are not zero + // which can happen if the element does not have any content. const sx = size.width / (scalableBBox.width || 1) const sy = size.height / (scalableBBox.height || 1) - scalable?.setAttribute('transform', `scale(${sx},${sy})`) + scalableNode.setAttribute('transform', `scale(${sx},${sy})`) // Now the interesting part. The goal is to be able to store the object geometry via just `x`, `y`, `angle`, `width` and `height` // Order of transformations is significant but we want to reconstruct the object always in the order: @@ -254,29 +295,330 @@ export class NodeView extends CellView { // and getting the top-left corner of the resulting object. Then we clean up the rotation back to what it originally was. // Cancel the rotation but now around a different origin, which is the center of the scaled object. - const rotatable = this.rotatableNode - const rotation = rotatable && rotatable.getAttribute('transform') - if (rotation) { - rotatable?.setAttribute( - 'transform', - `${rotation} rotate(${-angle},${size.width / 2},${size.height / 2})`, - ) - const rotatableBBox = v.getBBox(scalable as any, { - target: this.paper.cells, - }) - - // Store new x, y and perform rotate() again against the new rotation origin. - model.store.set( - 'position', - { x: rotatableBBox.x, y: rotatableBBox.y }, - { updateHandled: true, ...opt }, - ) - this.translate() - this.rotate() + const rotatableNode = this.rotatableNode + if (rotatableNode != null) { + const transform = rotatableNode.getAttribute('transform') + if (transform) { + rotatableNode.setAttribute( + 'transform', + `${transform} rotate(${-angle},${size.width / 2},${size.height / 2})`, + ) + const rotatableBBox = v.getBBox(scalableNode as SVGElement, { + target: this.graph.drawPane, + }) + + // Store new x, y and perform rotate() again against the new rotation origin. + cell.store.set( + 'position', + { x: rotatableBBox.x, y: rotatableBBox.y }, + { updated: true, ...opt }, + ) + this.translate() + this.rotate() + } } - // Update must always be called on non-rotated element. Otherwise, relative positioning - // would work with wrong (rotated) bounding boxes. + // Update must always be called on non-rotated element. Otherwise, + // relative positioning would work with wrong (rotated) bounding boxes. this.update() } + + // #region ports + + findPortNode(portId: string, selector: string) { + const cache = this.portsCache[portId] + if (!cache) { + return null + } + const portRoot = cache.portContentElement.node + const portSelectors = cache.portContentSelectors + return this.find(selector, portRoot, portSelectors)[0] + } + + protected initializePorts() { + this.cleanPortsCache() + } + + protected refreshPorts() { + this.removePorts() + this.cleanPortsCache() + this.renderPorts() + } + + protected cleanPortsCache() { + this.portsCache = {} + } + + protected renderPorts() { + // references to rendered elements without z-index + const elementReferences: Element[] = [] + const container = this.getPortsContainer() + + container.childNodes.forEach(child => { + elementReferences.push(child as Element) + }) + + const portsGropsByZ = ArrayExt.groupBy( + this.cell.portData.getPorts(), + 'zIndex', + ) + + const autoZIndexKey = 'auto' + // render non-z first + portsGropsByZ[autoZIndexKey].forEach(port => { + const portElement = this.getPortElement(port) + container.append(portElement) + elementReferences.push(portElement) + }) + + Object.keys(portsGropsByZ).forEach(key => { + if (key !== autoZIndexKey) { + const z = parseInt(key, 10) + this.appendPorts(portsGropsByZ[key], z, elementReferences) + } + }) + + this.updatePorts() + } + + protected getPortsContainer() { + return this.rotatableNode || this.container + } + + protected appendPorts( + ports: PortData.Port[], + zIndex: number, + refs: Element[], + ) { + const container = this.getPortsContainer() + const portElements = ports.map(p => this.getPortElement(p)) + if (refs[zIndex] || zIndex < 0) { + v(refs[Math.max(zIndex, 0)] as SVGElement).before(portElements) + } else { + portElements.forEach(elem => container.appendChild(elem)) + } + } + + protected getPortElement(port: PortData.Port) { + const cache = this.portsCache[port.id] + if (cache) { + return cache.portElement + } + return this.createPortElement(port) + } + + protected createPortElement(port: PortData.Port) { + const portElement = v(this.portContainerMarkup).addClass('x6-port').node + const portMarkup = this.getPortMarkup(port) + let rendered = this.renderPortMarkup(portMarkup) + const portContentElement = rendered.elem + const portContentSelectors = rendered.selectors + + if (portContentElement == null) { + throw new Error('Invalid port markup.') + } + + this.setAttributes( + { + port: port.id, + 'port-group': port.group, + }, + portContentElement, + ) + + const labelMarkup = this.getPortLabelMarkup(port.label) + rendered = this.renderPortMarkup(labelMarkup) + const portLabelElement = rendered.elem + const portLabelSelectors = rendered.selectors + + if (portLabelElement == null) { + throw new Error('Invalid port label markup.') + } + + let portSelectors: CellView.Selectors | null + if (portContentSelectors && portLabelSelectors) { + for (const key in portLabelSelectors) { + if (portContentSelectors[key] && key !== this.rootSelector) { + throw new Error('Selectors within port must be unique.') + } + } + portSelectors = { + ...portContentSelectors, + ...portLabelSelectors, + } + } else { + portSelectors = portContentSelectors || portLabelSelectors + } + + portElement.append([ + portContentElement.addClass('joint-port-body'), + portLabelElement.addClass('joint-port-label'), + ]) + + this.portsCache[port.id] = { + portElement, + portLabelElement, + portSelectors, + portLabelSelectors, + portContentElement, + portContentSelectors, + } + + return portElement + } + + protected renderPortMarkup(markup: BaseView.Markup) { + let elem: Element | null = null + let selectors: CellView.Selectors | null = null + + const createContainer = (child: Element) => + child instanceof SVGElement + ? v.createSvgElement('g') + : v.createElement('div') + + if (typeof markup === 'string') { + const nodes = v.batch(markup) + const count = nodes.length + if (count === 1) { + elem = nodes[0].node as Element + } else if (count > 1) { + elem = createContainer(nodes[0].node) + nodes.forEach(node => { + elem!.appendChild(node.node) + }) + } + + elem = null + } else { + const ret = this.parseDOMJSON(markup) + const fragment = ret.fragment + if (fragment.childNodes.length > 1) { + elem = createContainer(fragment.firstChild as Element) + elem.appendChild(fragment) + } else { + elem = fragment.firstChild as Element + } + selectors = ret.selectors + } + + return { elem, selectors } + } + + protected updatePorts() { + // layout ports without group + this.updatePortGroup(undefined) + // layout ports with explicit group + Object.keys(this.cell.portData.groups).forEach(groupName => + this.updatePortGroup(groupName), + ) + } + + protected removePorts() { + Object.keys(this.portsCache).forEach(id => { + const item = this.portsCache[id] + v.remove(item.portElement) + }) + } + + protected updatePortGroup(groupName?: string) { + const size = this.cell.size + const elementBBox = new Rectangle(0, 0, size.width, size.height) + const portsMetrics = this.cell.portData.getPortsLayoutByGroup( + groupName, + elementBBox, + ) + + for (let i = 0, n = portsMetrics.length; i < n; i += 1) { + const metrics = portsMetrics[i] + const portId = metrics.portId + const cached = this.portsCache[portId] || {} + const portLayout = metrics.portLayout + this.applyPortTransform(cached.portElement, portLayout) + if (metrics.portAttrs != null) { + const options: CellView.UpdateDOMSubtreeAttributesOptions = { + selectors: cached.portSelectors || {}, + } + + if (metrics.portSize) { + options.rootBBox = new Rectangle( + 0, + 0, + metrics.portSize.width, + metrics.portSize.height, + ) + } + + this.updateDOMSubtreeAttributes( + cached.portElement, + metrics.portAttrs, + options, + ) + } + + const portLabelLayout = metrics.portLabelLayout + if (portLabelLayout) { + this.applyPortTransform( + cached.portLabelElement, + portLabelLayout, + -portLayout.angle || 0, + ) + + if (portLabelLayout.attrs) { + const options: CellView.UpdateDOMSubtreeAttributesOptions = { + selectors: cached.portLabelSelectors || {}, + } + + if (metrics.labelSize) { + options.rootBBox = new Rectangle( + 0, + 0, + metrics.labelSize.width, + metrics.labelSize.height, + ) + } + + this.updateDOMSubtreeAttributes( + cached.portLabelElement, + portLabelLayout.attrs, + options, + ) + } + } + } + } + + protected applyPortTransform( + element: Element, + transformData, + initialAngle: number = 0, + ) { + const matrix = v + .createSVGMatrix() + .rotate(initialAngle) + .translate(transformData.x || 0, transformData.y || 0) + .rotate(transformData.angle || 0) + + v.transform(element as SVGElement, matrix, { absolute: true }) + } + + protected getPortMarkup(port: PortData.Port) { + return port.markup || this.cell.portMarkup || this.portMarkup + } + + protected getPortLabelMarkup(label: PortData.Label) { + return label.markup || this.cell.portLabelMarkup || this.portLabelMarkup + } + + // #endregion +} + +export namespace NodeView { + export interface PortCache { + portElement: Element + portSelectors: CellView.Selectors | null + portLabelElement: Element + portLabelSelectors: CellView.Selectors | null + portContentElement: Element + portContentSelectors: CellView.Selectors | null + } } diff --git a/packages/x6/src/research/core/node.ts b/packages/x6/src/research/core/node.ts index c2da7085a90..b8868961565 100644 --- a/packages/x6/src/research/core/node.ts +++ b/packages/x6/src/research/core/node.ts @@ -1,11 +1,15 @@ +import cloneDeep from 'lodash/cloneDeep' import { Cell } from './cell' import { Size } from '../../types' import { Point, Rectangle, Angle } from '../../geometry' +import { PortData } from './port-data' +import { StringExt, ObjectExt } from '../../util' export class Node extends Cell { protected collapsed: boolean protected collapsedSize: Size | null protected edges: Cell[] | null + public portData: PortData constructor(options: Node.CreateNodeOptions = {}) { super(options) @@ -414,6 +418,243 @@ export class Node extends Cell { // return this // } + + // #region ports + + get ports() { + return this.store.get('ports') || { items: [] } + } + + get portMarkup() { + return this.store.get('portMarkup') + } + + get portLabelMarkup() { + return this.store.get('portLabelMarkup') + } + + getPorts() { + return cloneDeep(this.ports.items) + } + + getPort(id: string) { + return cloneDeep(this.ports.items.find(port => port.id && port.id === id)) + } + + hasPorts() { + return this.ports.items.length > 0 + } + + hasPort(id: string) { + return this.getPortIndex(id) !== -1 + } + + getPortIndex(port: PortData.PortMetadata | string) { + const id = typeof port === 'string' ? port : port.id + return id != null ? this.ports.items.findIndex(item => item.id === id) : -1 + } + + getPortsPositions(groupName: string) { + const size = this.size + const layouts = this.portData.getPortsLayoutByGroup( + groupName, + new Rectangle(0, 0, size.width, size.height), + ) + + const positions: { + [id: string]: { + x: number + y: number + angle: number + } + } = {} + + return layouts.reduce((memo, item) => { + const transformation = item.portLayout + memo[item.portId] = { + x: transformation.x, + y: transformation.y, + angle: transformation.angle, + } + return memo + }, positions) + } + + addPort(port: PortData.PortMetadata, options?: Cell.SetPropByPathOptions) { + const ports = [...this.ports.items] + ports.push(port) + this.setPropByPath('ports/items', ports, options) + return this + } + + addPorts( + ports: PortData.PortMetadata[], + options?: Cell.SetPropByPathOptions, + ) { + this.setPropByPath('ports/items', [...this.ports.items, ...ports], options) + return this + } + + removePort( + port: PortData.PortMetadata | string, + options: Cell.SetPropByPathOptions = {}, + ) { + const ports = [...this.ports.items] + const index = this.getPortIndex(port) + + if (index !== -1) { + ports.splice(index, 1) + options.rewrite = true + this.setPropByPath('ports/items', ports, options) + } + + return this + } + + removePorts(options?: Cell.SetPropByPathOptions): this + removePorts( + portsForRemoval: (PortData.PortMetadata | string)[], + options?: Cell.SetPropByPathOptions, + ): this + removePorts( + portsForRemoval?: + | (PortData.PortMetadata | string)[] + | Cell.SetPropByPathOptions, + opt?: Cell.SetPropByPathOptions, + ) { + let options + + if (Array.isArray(portsForRemoval)) { + options = opt || {} + if (portsForRemoval.length) { + options.rewrite = true + const currentPorts = [...this.ports.items] + const remainingPorts = currentPorts.filter( + cp => + !portsForRemoval.some(p => { + const id = typeof p === 'string' ? p : p.id + return cp.id === id + }), + ) + this.setPropByPath('ports/items', remainingPorts, options) + } + } else { + options = portsForRemoval || {} + options.rewrite = true + this.setPropByPath('ports/items', [], options) + } + + return this + } + + protected initializePorts() { + this.createPortData() + this.on('change:ports', () => { + this.processRemovedPort() + this.createPortData() + }) + } + + protected processRemovedPort() { + const current = this.ports + const currentItemsMap: { [id: string]: boolean } = {} + + current.items.forEach(item => { + if (item.id) { + currentItemsMap[item.id] = true + } + }) + + const removed: { [id: string]: boolean } = {} + const previous = this.store.getPrevious('ports') || { + items: [], + } + + previous.items.forEach(item => { + if (item.id && !currentItemsMap[item.id]) { + removed[item.id] = true + } + }) + + const model = this.model + if (model && !ObjectExt.isEmpty(removed)) { + // const inboundLinks = model.getConnectedLinks(this, { inbound: true }) + // inboundLinks.forEach(link => { + // if (removed[link.get('target').port]) { + // link.remove() + // } + // }) + // const outboundLinks = model.getConnectedLinks(this, { outbound: true }) + // outboundLinks.forEach(link => { + // if (removed[link.get('source').port]) { + // link.remove() + // } + // }) + } + } + + protected validatePorts() { + const ids: { [id: string]: boolean } = {} + const errors: string[] = [] + this.ports.items.forEach(p => { + if (typeof p !== 'object') { + errors.push('Invalid port ', p) + } + + if (p.id == null) { + p.id = this.generatePortId() + } + + if (ids[p.id]) { + errors.push('Duplicitied port id.') + } + + ids[p.id] = true + }) + + return errors + } + + protected generatePortId() { + return StringExt.uuid() + } + + protected createPortData() { + const err = this.validatePorts() + + if (err.length > 0) { + this.store.set('ports', this.store.getPrevious('ports')) + throw new Error(err.join(' ')) + } + + const prevPorts = this.portData ? this.portData.getPorts() : null + this.portData = new PortData(this.ports) + const curPorts = this.portData.getPorts() + + if (prevPorts) { + const added = curPorts.filter(item => { + if (!prevPorts.find(prevPort => prevPort.id === item.id)) { + return item + } + }) + + const removed = prevPorts.filter(item => { + if (!curPorts.find(curPort => curPort.id === item.id)) { + return item + } + }) + + if (removed.length > 0) { + this.trigger('ports:remove', this, removed) + } + + if (added.length > 0) { + this.trigger('ports:add', this, added) + } + } + } + + // #endregion } Node.config({ diff --git a/packages/x6/src/research/core/port-data.ts b/packages/x6/src/research/core/port-data.ts index cf34bb32735..cf6409c81f8 100644 --- a/packages/x6/src/research/core/port-data.ts +++ b/packages/x6/src/research/core/port-data.ts @@ -9,7 +9,7 @@ export class PortData { ports: PortData.Port[] groups: { [name: string]: PortData.Group } - constructor(data: PortData.Data) { + constructor(data: PortData.Metadata) { this.ports = [] this.groups = {} this.init(JSONExt.deepCopy(data as any)) @@ -27,7 +27,7 @@ export class PortData { return this.ports.filter(port => port.group === groupName) } - getGroupPortsMetrics(groupName: string, elemBBox: Rectangle) { + getPortsLayoutByGroup(groupName: string, elemBBox: Rectangle) { const group = this.getGroup(groupName) const ports = this.getPortsByGroup(groupName) @@ -48,18 +48,19 @@ export class PortData { groupArgs, ) - const accumulator: PortData.Metadata = { + const accumulator: PortData.ParsedPorts = { ports, - result: [], + items: [], } results.reduce((memo, portLayout, index) => { const port = memo.ports[index] - memo.result.push({ + memo.items.push({ portLayout, - portId: port.id, + portId: port.id!, portSize: port.size, portAttrs: port.attrs, + labelSize: port.label.size, portLabelLayout: this.getPortLabelLayout( port, Point.create(portLayout), @@ -69,10 +70,10 @@ export class PortData { return memo }, accumulator) - return accumulator.result + return accumulator.items } - protected init(data: PortData.Data) { + protected init(data: PortData.Metadata) { const { groups, items } = data if (groups != null) { @@ -88,7 +89,7 @@ export class PortData { } } - protected evaluateGroup(group: PortData.GroupData) { + protected evaluateGroup(group: PortData.GroupMetadata) { return { ...group, label: this.getLabel(group, true), @@ -96,7 +97,7 @@ export class PortData { } as PortData.Group } - protected evaluatePort(port: PortData.PortData) { + protected evaluatePort(port: PortData.PortMetadata) { const result = { ...port } as PortData.Port const group = this.getGroup(port.group) @@ -110,7 +111,7 @@ export class PortData { return result } - protected getZIndex(group: PortData.Group, port: PortData.PortData) { + protected getZIndex(group: PortData.Group, port: PortData.PortMetadata) { if (typeof port.zIndex === 'number') { return port.zIndex } @@ -122,7 +123,7 @@ export class PortData { return 'auto' } - protected createPosition(group: PortData.Group, port: PortData.PortData) { + protected createPosition(group: PortData.Group, port: PortData.PortMetadata) { return { name: 'left', ...group.position, @@ -131,7 +132,7 @@ export class PortData { } protected getPortPosition( - position?: PortData.PortPositionData, + position?: PortData.PortPositionMetadata, setDefault: boolean = false, ): PortData.PortPosition { if (position == null) { @@ -169,7 +170,7 @@ export class PortData { } protected getPortLabelPosition( - position?: PortData.PortLabelPositionData, + position?: PortData.PortLabelPositionMetadata, setDefault: boolean = false, ): PortData.PortLabelPosition { if (position == null) { @@ -192,7 +193,10 @@ export class PortData { return { args: {} } } - protected getLabel(item: PortData.GroupData, setDefaults: boolean = false) { + protected getLabel( + item: PortData.GroupMetadata, + setDefaults: boolean = false, + ) { const label = item.label || {} label.position = this.getPortLabelPosition(label.position, setDefaults) return label as PortData.Label @@ -213,9 +217,9 @@ export class PortData { } export namespace PortData { - export interface Data { - groups?: { [name: string]: GroupData } - items: PortData[] + export interface Metadata { + groups?: { [name: string]: GroupMetadata } + items: PortMetadata[] } export interface PortPosition< @@ -225,7 +229,7 @@ export namespace PortData { args: PortLayout.LayoutArgs[T] } - export type PortPositionData = + export type PortPositionMetadata = | PortLayout.LayoutNames | Point.PointData // absolute layout | PortPosition @@ -238,17 +242,19 @@ export namespace PortData { args: PortLabelLayout.LayoutArgs[T] } - export type PortLabelPositionData = + export type PortLabelPositionMetadata = | PortLabelLayout.LayoutNames | PortLabelPosition - export interface LabelData { + export interface LabelMetadata { markup?: string - position?: PortLabelPositionData + size?: Size + position?: PortLabelPositionMetadata } export interface Label { markup: string + size?: Size position: PortLabelPosition } @@ -256,11 +262,10 @@ export namespace PortData { markup: string attrs: Attribute.CellAttributes zIndex: number | 'auto' - size: Size + size?: Size } interface PortBase { - id?: string group: string /** * Arguments for the port layout function. @@ -268,9 +273,9 @@ export namespace PortData { args?: JSONObject } - export interface GroupData extends Partial { - label?: LabelData - position?: PortPositionData + export interface GroupMetadata extends Partial { + label?: LabelMetadata + position?: PortPositionMetadata } export interface Group extends Partial { @@ -278,22 +283,26 @@ export namespace PortData { position: PortPosition } - export interface PortData extends Partial, PortBase { - label?: LabelData + export interface PortMetadata extends Partial, PortBase { + id?: string + label?: LabelMetadata } - export interface Port extends Group, PortBase {} + export interface Port extends Group, PortBase { + id: string + } export interface LayoutResult { - portId?: string + portId: string portAttrs?: Attribute.CellAttributes portSize?: Size portLayout: PortLayout.Result portLabelLayout: PortLabelLayout.Result | null + labelSize?: Size } - export interface Metadata { + export interface ParsedPorts { ports: Port[] - result: LayoutResult[] + items: LayoutResult[] } } diff --git a/packages/x6/src/research/core/reg.ts b/packages/x6/src/research/core/reg.ts new file mode 100644 index 00000000000..bffec278696 --- /dev/null +++ b/packages/x6/src/research/core/reg.ts @@ -0,0 +1,11 @@ +import { Attribute } from '../attr' +import { Size } from '../../types' + +export interface RegisterNodeOptions { + size?: Size + attrs?: Attribute.CellAttributes + markup?: string + init?: () => void +} + +export function registerNode(name: string, options: RegisterNodeOptions) {} diff --git a/packages/x6/src/research/core/store.ts b/packages/x6/src/research/core/store.ts index 4c939ba98f3..7b5f82468a1 100644 --- a/packages/x6/src/research/core/store.ts +++ b/packages/x6/src/research/core/store.ts @@ -1,7 +1,8 @@ import { Assign } from 'utility-types' +import merge from 'lodash/merge' import { Basecoat } from '../../entity' import { KeyValue } from '../../types' -import { JSONObject, JSONExt } from '../../util' +import { JSONObject, JSONExt, ObjectExt, JSONValue } from '../../util' export class Store< D extends JSONObject = JSONObject, @@ -93,6 +94,8 @@ export class Store< this.pending = false this.changing = false this.pendingOptions = null + + return this } get(key: K) { @@ -100,16 +103,17 @@ export class Store< return ret == null ? null : ((ret as any) as T) } - getPrevious(key: K) { + getPrevious(key: K) { if (this.previous) { - return this.previous[key] + const ret = this.previous[key] + return ret == null ? null : ((ret as any) as T) } return null } set(key: K, value: D[K], options?: Store.SetOptions): this - set(subset: Partial, options?: Store.SetOptions): this + set(data: D, options?: Store.SetOptions): this set( key: K | Partial, value?: D[K] | Store.SetOptions, @@ -146,7 +150,81 @@ export class Store< opts = key } - return this.mutate(subset, { ...opts, unset: true }) + this.mutate(subset, { ...opts, unset: true }) + return this + } + + getByPath(path: string | string[]) { + return ObjectExt.getByPath(this.data, path, '/') as T + } + + setByPath( + path: string | string[], + value: any, + options: Store.SetByPathOptions = {}, + ) { + const delim = '/' + const pathArray = Array.isArray(path) ? path : path.split(delim) + const pathString = Array.isArray(path) ? path.join(delim) : path + + const property = pathArray[0] as K + const pathArrayLength = pathArray.length + + options.propertyPath = pathString + options.propertyValue = value + options.propertyPathArray = pathArray + + if (pathArrayLength === 1) { + this.set(property, value, options) + } else { + const update: KeyValue = {} + let diver = update + let nextKey: string = property + + // Initialize the nested object. Subobjects are either arrays or objects. + // An empty array is created if the sub-key is an integer. Otherwise, an + // empty object is created. + for (let i = 1; i < pathArrayLength; i += 1) { + const key = pathArray[i] + const isArrayIndex = Number.isFinite(Number(key)) + diver = diver[nextKey] = isArrayIndex ? [] : {} + nextKey = key + } + + // Fills update with the `value` on `path`. + ObjectExt.setByPath(update, pathArray, value, delim) + + const data = JSONExt.deepCopy(this.data) + + // If rewrite mode enabled, we replace value referenced by path with the + // new one (we don't merge). + if (options.rewrite) { + ObjectExt.unsetByPath(data, path, delim) + } + + const merged = merge(data, update) + this.set(property, merged[property], options) + } + + return this + } + + removeByPath(path: string | string[], options?: Store.SetOptions) { + const keys = Array.isArray(path) ? path : path.split('/') + const key = keys[0] as K + if (keys.length === 1) { + this.remove(key, options) + } else { + const paths = keys.slice(1) + const prop = JSONExt.deepCopy(this.get(key)) + if (prop) { + ObjectExt.unsetByPath(prop, paths) + } + + this.set(key, prop as D[K], options) + } + + return this } hasChanged(key?: K | null) { @@ -210,6 +288,10 @@ export namespace Store { silent?: boolean } + export interface SetByPathOptions extends SetOptions { + rewrite?: boolean + } + export interface MutateOptions extends SetOptions { unset?: boolean }