Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[POC] Experiment BPMN diagram navigation with CSS Transforms #2199

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
351 changes: 348 additions & 3 deletions src/component/mxgraph/BpmnGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,62 @@ import debounce from 'lodash.debounce';
import throttle from 'lodash.throttle';
import { mxgraph } from './initializer';
import type { mxCellState, mxGraphView, mxPoint } from 'mxgraph';
import type { mxCell } from 'mxgraph';
import type { mxRectangle } from 'mxgraph';

// TODO change value when using CSS transform (we should have different values depending on the useCssTransforms value
const zoomFactorIn = 1.25;
const zoomFactorOut = 1 / zoomFactorIn;

const enableAllPocLog = false;

const enableLogPanGraph = false;
function logPanGraph(msg: string): void {
// eslint-disable-next-line no-console
(enableLogPanGraph || enableAllPocLog) && console.info(`<panGraph> - ${msg}`);
}

const enableLogRegisterMouseListener = false;
function logRegisterMouseListener(msg: string): void {
// eslint-disable-next-line no-console
(enableLogRegisterMouseListener || enableAllPocLog) && console.info(`<registerMouseWheelZoomListeners> - ${msg}`);
}

const enableLogUpdateCssTransform = false;
function logUpdateCssTransform(msg: string): void {
// eslint-disable-next-line no-console
(enableLogUpdateCssTransform || enableAllPocLog) && console.info(`<updateCssTransform> - ${msg}`);
}

const enableLogValidateBackgroundPage = false;
function logValidateBackgroundPage(msg: string): void {
// eslint-disable-next-line no-console
(enableLogValidateBackgroundPage || enableAllPocLog) && console.info(`<validateBackgroundPage> - ${msg}`);
}

export class BpmnGraph extends mxgraph.mxGraph {
private currentZoomLevel = 1;

// ===========================================================================
// POC transform with CSS
// ===========================================================================
/**
* Uses CSS transforms for scale and translate.
*/
// TODO make this configurable
useCssTransforms = true;

/**
* Contains the scale.
*/
currentScale = 1;

/**
* Contains the offset.
*/
currentTranslate = new mxgraph.mxPoint(0, 0);
// ===========================================================================

/**
* @internal
*/
Expand Down Expand Up @@ -139,9 +188,16 @@ export class BpmnGraph extends mxgraph.mxGraph {
* @internal
*/
registerMouseWheelZoomListeners(config: ZoomConfiguration): void {
config = ensureValidZoomConfiguration(config);
mxgraph.mxEvent.addMouseWheelListener(debounce(this.createMouseWheelZoomListener(true), config.debounceDelay), this.container);
mxgraph.mxEvent.addMouseWheelListener(throttle(this.createMouseWheelZoomListener(false), config.throttleDelay), this.container);
logRegisterMouseListener('start');
if (!this.useCssTransforms) {
logRegisterMouseListener('NO useCssTransforms - use throttle/debounce for zoom');
config = ensureValidZoomConfiguration(config);
mxgraph.mxEvent.addMouseWheelListener(debounce(this.createMouseWheelZoomListener(true), config.debounceDelay), this.container);
mxgraph.mxEvent.addMouseWheelListener(throttle(this.createMouseWheelZoomListener(false), config.throttleDelay), this.container);
} else {
logRegisterMouseListener('detected useCssTransforms - use poc code');
mxgraph.mxEvent.addMouseWheelListener(this.createMouseWheelZoomListenerForCssTransforms(), this.container);
}
}

// Update the currentZoomLevel when performScaling is false, use the currentZoomLevel to set the scale otherwise
Expand All @@ -156,6 +212,34 @@ export class BpmnGraph extends mxgraph.mxGraph {
mxgraph.mxEvent.consume(evt);
}
}
// TODO duplication with createMouseWheelZoomListener
private createMouseWheelZoomListenerForCssTransforms() {
return (event: Event, up: boolean) => {
if (mxgraph.mxEvent.isConsumed(event)) {
return;
}
const evt = event as MouseEvent;
// only the ctrl key
const isZoomWheelEvent = evt.ctrlKey && !evt.altKey && !evt.shiftKey && !evt.metaKey;
if (isZoomWheelEvent) {
this.manageMouseWheelZoomEventForCssTransforms(up, evt);
}
};
}

private manageMouseWheelZoomEventForCssTransforms(up: boolean, evt: MouseEvent): void {
const factor = up ? zoomFactorIn : zoomFactorOut;

// TODO use view.scale instead of currentScale?
const newScale = this.currentScale * factor;

const [offsetX, offsetY] = this.getEventRelativeCoordinates(evt);
// const [newScale, dx, dy] = this.getScaleAndTranslationDeltas(offsetX, offsetY);
const [dx, dy] = this.calculateTranslationDeltas(factor, newScale, offsetX * 2, offsetY * 2);

this.view.scaleAndTranslate(newScale, this.view.translate.x + dx, this.view.translate.y + dy);
mxgraph.mxEvent.consume(evt);
}

private createMouseWheelZoomListener(performScaling: boolean) {
return (event: Event, up: boolean) => {
Expand All @@ -172,6 +256,8 @@ export class BpmnGraph extends mxgraph.mxGraph {
}

private getEventRelativeCoordinates(evt: MouseEvent): [number, number] {
// TODO EXTRA mouse zoom impl probably better with call of mxUtils.convertPoint
// mxgraph.mxUtils.convertPoint(this.container, mxgraph.mxEvent.getClientX(evt), mxgraph.mxEvent.getClientY(evt));
const rect = this.container.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
Expand Down Expand Up @@ -204,6 +290,178 @@ export class BpmnGraph extends mxgraph.mxGraph {
const factor = scale / this.view.scale;
return [factor, scale];
}

// ===========================================================================
// POC transform with CSS
// ===========================================================================

/**
* Function: getCellAt
*
* Needs to modify original method for recursive call.
*/
// eslint-disable-next-line @typescript-eslint/ban-types -- Function is type is required by typed-mxgraph
override getCellAt(x: number, y: number, parent?: mxCell, vertices?: boolean, edges?: boolean, ignoreFn?: Function): mxCell {
if (this.useCssTransforms) {
x = x / this.currentScale - this.currentTranslate.x;
y = y / this.currentScale - this.currentTranslate.y;
}

return this.getScaledCellAt(x, y, parent, vertices, edges, ignoreFn);
}

/**
* Function: getScaledCellAt
*
* Overridden for recursion.
*/
// eslint-disable-next-line @typescript-eslint/ban-types -- Function is type is required by typed-mxgraph
private getScaledCellAt(x: number, y: number, parent?: mxCell, vertices?: boolean, edges?: boolean, ignoreFn?: Function): mxCell {
vertices = vertices != null ? vertices : true;
edges = edges != null ? edges : true;

if (parent == null) {
parent = this.getCurrentRoot();

if (parent == null) {
parent = this.getModel().getRoot();
}
}

if (parent != null) {
const childCount = this.model.getChildCount(parent);

for (let i = childCount - 1; i >= 0; i--) {
const cell = this.model.getChildAt(parent, i);
const result = this.getScaledCellAt(x, y, cell, vertices, edges, ignoreFn);

if (result != null) {
return result;
} else if (this.isCellVisible(cell) && ((edges && this.model.isEdge(cell)) || (vertices && this.model.isVertex(cell)))) {
const state = this.view.getState(cell);

if (state != null && (ignoreFn == null || !ignoreFn(state, x, y)) && this.intersects(state, x, y)) {
return cell;
}
}
}
}

return null;
}

// TODO check scrollRectToVisible - not used by bpmn-visualization today
// in draw.io code, the implementation is updated
override scrollRectToVisible(r: mxRectangle): boolean {
console.warn('#######Called scrollRectToVisible!');
return super.scrollRectToVisible(r);
}

/**
* Only foreignObject supported for now (no IE11). Safari disabled as it ignores
* overflow visible on foreignObject in negative space (lightbox and viewer).
* Check the following test case on page 1 before enabling this in production:
* https://devhost.jgraph.com/git/drawio/etc/embed/sf-math-fo-clipping.html?dev=1
*/
// we test no Safari latest and it works
// isCssTransformsSupported(): boolean {
// // this.updateCssTransform
// return this.dialect == mxgraph.mxConstants.DIALECT_SVG && !mxgraph.mxClient.NO_FO && !mxgraph.mxClient.IS_SF;
// // return this.dialect == mxgraph.mxConstants.DIALECT_SVG && !mxgraph.mxClient.NO_FO && (!this.lightbox || !mxgraph.mxClient.IS_SF);
// }

/**
* Zooms out of the graph by <zoomFactor>.
*/
updateCssTransform(): void {
logUpdateCssTransform('start');
const temp = this.view.getDrawPane();

if (temp != null) {
// TODO consistency, the parentNode is supposed to be this.view.getCanvas(), why not calling it directly?
const g = <SVGElement>temp.parentNode;

// TODO this check is probably not needed in our implementation as updateCssTransform is only called when useCssTransforms is true
if (!this.useCssTransforms) {
logUpdateCssTransform('useCssTransforms = false');
g.removeAttribute('transformOrigin');
g.removeAttribute('transform');
} else {
logUpdateCssTransform('useCssTransforms');
const prev = g.getAttribute('transform');

// TODO remove setting attribute transformOrigin
// the transformOrigin attribute seems specific to the draw.io/mxgraph EditorUI implementation
g.setAttribute('transformOrigin', '0 0');
const s = Math.round(this.currentScale * 100) / 100;
const dx = Math.round(this.currentTranslate.x * 100) / 100;
const dy = Math.round(this.currentTranslate.y * 100) / 100;
const computedTransformDirective = `scale(${s},${s})translate(${dx},${dy})`;
logUpdateCssTransform(`computed transform directive: ${computedTransformDirective}`);
g.setAttribute('transform', computedTransformDirective);

// Applies workarounds only if translate has changed
if (prev != g.getAttribute('transform')) {
logUpdateCssTransform('transform value changed');
// TODO make the implem pass type check, disable 'cssTransformChanged' event firing for now
// this.fireEvent(new mxgraph.mxEventObject('cssTransformChanged'), 'transform', g.getAttribute('transform'));
}
}
}
}

// ===========================================================================
// custom for bpmn-visualization, draw.io is using scrollbar, without scrollbar, the original mxGraph implementation doesn't work
// ===========================================================================

/**
* Function: panGraph
*
* Shifts the graph display by the given amount. This is used to preview
* panning operations, use <mxGraphView.setTranslate> to set a persistent
* translation of the view. Fires <mxEvent.PAN>.
*
* Parameters:
*
* dx - Amount to shift the graph along the x-axis.
* dy - Amount to shift the graph along the y-axis.
*/
override panGraph(dx: number, dy: number): void {
if (!this.useCssTransforms) {
logPanGraph('no useCssTransforms');
super.panGraph(dx, dy);
} else {
logPanGraph('useCssTransforms');
if (this.useScrollbarsForPanning && mxgraph.mxUtils.hasScrollbars(this.container)) {
logPanGraph('has scrollbars');
this.container.scrollLeft = -dx;
this.container.scrollTop = -dy;
} else {
// at the end of pan, the panning handler does
// var scale = this.graph.getView().scale;
// var t = this.graph.getView().translate;
// <panningHandler>this.panGraph(t.x + this.dx / scale, t.y + this.dy / scale);
// <panningHandler>panGraph = function(dx, dy) ==> this.graph.getView().setTranslate(dx, dy);

logPanGraph(`useCssTransforms - dx=${dx} dy=${dy}`);
// TODO manage rounding duplication with updateCssTransform (introduce private function roundKeepLastTwoDecimals)
// 1.4594 --> 1.45
const roundedCurrentScale = Math.round(this.currentScale * 100) / 100;
const roundedPannedTranslateX = Math.round((this.currentTranslate.x + dx / this.currentScale) * 100) / 100;
const roundedPannedTranslateY = Math.round((this.currentTranslate.y + dy / this.currentScale) * 100) / 100;
const computedTransformDirective = `scale(${roundedCurrentScale},${roundedCurrentScale})translate(${roundedPannedTranslateX},${roundedPannedTranslateY})`;
logPanGraph(`computed transform directive: ${computedTransformDirective}`);

const canvas = this.view.getCanvas();
canvas.setAttribute('transform', computedTransformDirective);
}

this.panDx = dx;
this.panDy = dy;

this.fireEvent(new mxgraph.mxEventObject(mxgraph.mxEvent.PAN), undefined);
}
}
}

class BpmnGraphView extends mxgraph.mxGraphView {
Expand All @@ -218,4 +476,91 @@ class BpmnGraphView extends mxgraph.mxGraphView {
const pts = edge.absolutePoints;
return source ? pts[1] : pts[pts.length - 2];
}

// ===========================================================================
// POC transform with CSS
// ===========================================================================
// TODO improve types: make this.graph considered as BpmnGraph out of the box

/**
* Overrides getGraphBounds to use bounding box from SVG.
*/
override getGraphBounds(): mxRectangle {
let b = this.graphBounds;

if ((<BpmnGraph>this.graph).useCssTransforms) {
const t = (<BpmnGraph>this.graph).currentTranslate;
const s = (<BpmnGraph>this.graph).currentScale;

b = new mxgraph.mxRectangle((b.x + t.x) * s, (b.y + t.y) * s, b.width * s, b.height * s);
}
return b;
}

/**
* Overrides to bypass full cell tree validation.
* TODO: Check if this improves performance
*/
override viewStateChanged(): void {
if ((<BpmnGraph>this.graph).useCssTransforms) {
this.validate();
this.graph.sizeDidChange();
} else {
this.revalidate();
this.graph.sizeDidChange();
}
}

/**
* Overrides validate to normalize validation view state and pass
* current state to CSS transform.
*/
// var graphViewValidate = mxGraphView.prototype.validate;
override validate(cell?: mxCell): void {
if ((<BpmnGraph>this.graph).useCssTransforms) {
(<BpmnGraph>this.graph).currentScale = this.scale;
(<BpmnGraph>this.graph).currentTranslate.x = this.translate.x;
(<BpmnGraph>this.graph).currentTranslate.y = this.translate.y;

this.scale = 1;
this.translate.x = 0;
this.translate.y = 0;
}

// graphViewValidate.apply(this, arguments);
super.validate(cell);

if ((<BpmnGraph>this.graph).useCssTransforms) {
(<BpmnGraph>this.graph).updateCssTransform();

this.scale = (<BpmnGraph>this.graph).currentScale;
this.translate.x = (<BpmnGraph>this.graph).currentTranslate.x;
this.translate.y = (<BpmnGraph>this.graph).currentTranslate.y;
}
}

// TODO check if validateBackgroundPage is used by bpmn-visualization today, otherwise remove override
// var graphViewValidateBackgroundPage = mxGraphView.prototype.validateBackgroundPage;
override validateBackgroundPage(): void {
logValidateBackgroundPage('start');
const useCssTransforms = (<BpmnGraph>this.graph).useCssTransforms,
scale = this.scale,
translate = this.translate;

if (useCssTransforms) {
this.scale = (<BpmnGraph>this.graph).currentScale;
this.translate = (<BpmnGraph>this.graph).currentTranslate;
}

// graphViewValidateBackgroundPage.apply(this, arguments);
super.validateBackgroundPage();

if (useCssTransforms) {
this.scale = scale;
this.translate = translate;
}
logValidateBackgroundPage('end');
}

// TODO check updatePageBreaks - not used by bpmn-visualization today
}