From 8f7f91a71b6fbac9af45a154b749d428856897ce Mon Sep 17 00:00:00 2001 From: Philipp Date: Fri, 29 Sep 2023 10:11:54 +0200 Subject: [PATCH] feat: add complex preview feature --- .../complex-preview/ComplexPreview.js | 155 ++++++++++ lib/features/complex-preview/index.js | 12 + .../preview-support/PreviewSupport.js | 4 + .../ConnectionPreviewSpec.js | 10 +- .../complex-preview/ComplexPreviewSpec.js | 249 ++++++++++++++++ test/spec/features/move/MovePreviewSpec.js | 10 +- .../renderer/MarkerRenderer.js | 280 +++++++++--------- .../renderer/index.js | 10 +- 8 files changed, 575 insertions(+), 155 deletions(-) create mode 100644 lib/features/complex-preview/ComplexPreview.js create mode 100644 lib/features/complex-preview/index.js create mode 100644 test/spec/features/complex-preview/ComplexPreviewSpec.js rename test/spec/features/{move => preview-support}/renderer/MarkerRenderer.js (94%) rename test/spec/features/{move => preview-support}/renderer/index.js (96%) diff --git a/lib/features/complex-preview/ComplexPreview.js b/lib/features/complex-preview/ComplexPreview.js new file mode 100644 index 000000000..b93e74c27 --- /dev/null +++ b/lib/features/complex-preview/ComplexPreview.js @@ -0,0 +1,155 @@ +import { + clear as svgClear, + create as svgCreate +} from 'tiny-svg'; + +import { getVisual } from '../../util/GraphicsUtil'; + +import { isConnection } from '../../util/ModelUtil'; + +import { translate } from '../../util/SvgTransformUtil'; + +/** + * @typedef {import('../../model/Types').Element} Element + * @typedef {import('../../model/Types').Shape} Shape + * @typedef {import('../../util/Types').Point} Point + * @typedef {import('../../util/Types').Rect} Rect + * + * @typedef { { element: Element, delta: Point } } MovedOption + * @typedef { { shape: Shape, bounds: Rect } } ResizedOption + * + * @typedef { { + * created?: Element[], + * removed?: Element[], + * moved?: MovedOption[], + * resized?: ResizedOption[] + * } } CreateOptions + */ + +const LAYER_NAME = 'complex-preview'; + +/** + * Complex preview for shapes and connections. + */ +export default class ComplexPreview { + constructor(canvas, graphicsFactory, previewSupport) { + this._canvas = canvas; + this._graphicsFactory = graphicsFactory; + this._previewSupport = previewSupport; + + this._markers = []; + } + + /** + * Create complex preview. + * + * @param {CreateOptions} options + */ + create(options) { + + // there can only be one complex preview at a time + this.cleanUp(); + + const { + created = [], + moved = [], + removed = [], + resized = [] + } = options; + + const layer = this._canvas.getLayer(LAYER_NAME); + + // shapes and connections to be created + created.filter(element => !isHidden(element)).forEach(element => { + let gfx; + + if (isConnection(element)) { + gfx = this._graphicsFactory._createContainer('connection', svgCreate('g')); + + this._graphicsFactory.drawConnection(getVisual(gfx), element); + } else { + gfx = this._graphicsFactory._createContainer('shape', svgCreate('g')); + + this._graphicsFactory.drawShape(getVisual(gfx), element); + + translate(gfx, element.x, element.y); + } + + this._previewSupport.addDragger(element, layer, gfx); + }); + + // elements to be moved + moved.forEach(({ element, delta }) => { + this._previewSupport.addDragger(element, layer, undefined, 'djs-dragging'); + + this._canvas.addMarker(element, 'djs-element-hidden'); + + this._markers.push([ element, 'djs-element-hidden' ]); + + const dragger = this._previewSupport.addDragger(element, layer); + + if (isConnection(element)) { + translate(dragger, delta.x, delta.y); + } else { + translate(dragger, element.x + delta.x, element.y + delta.y); + } + }); + + // elements to be removed + removed.forEach(element => { + this._previewSupport.addDragger(element, layer, undefined, 'djs-dragging'); + + this._canvas.addMarker(element, 'djs-element-hidden'); + + this._markers.push([ element, 'djs-element-hidden' ]); + }); + + // elements to be resized + resized.forEach(({ shape, bounds }) => { + this._canvas.addMarker(shape, 'djs-hidden'); + + this._markers.push([ shape, 'djs-hidden' ]); + + this._previewSupport.addDragger(shape, layer, undefined, 'djs-dragging'); + + const gfx = this._graphicsFactory._createContainer('shape', svgCreate('g')); + + this._graphicsFactory.drawShape(getVisual(gfx), shape, { + width: bounds.width, + height: bounds.height + }); + + translate(gfx, bounds.x, bounds.y); + + this._previewSupport.addDragger(shape, layer, gfx); + }); + } + + cleanUp() { + svgClear(this._canvas.getLayer(LAYER_NAME)); + + this._markers.forEach(([ element, marker ]) => this._canvas.removeMarker(element, marker)); + + this._markers = []; + + this._previewSupport.cleanUp(); + } + + show() { + this._canvas.showLayer(LAYER_NAME); + } + + hide() { + this._canvas.hideLayer(LAYER_NAME); + } +} + +ComplexPreview.$inject = [ + 'canvas', + 'graphicsFactory', + 'previewSupport' +]; + +function isHidden(element) { + return element.hidden; +} \ No newline at end of file diff --git a/lib/features/complex-preview/index.js b/lib/features/complex-preview/index.js new file mode 100644 index 000000000..8d6dbe5b0 --- /dev/null +++ b/lib/features/complex-preview/index.js @@ -0,0 +1,12 @@ +import PreviewSupportModule from '../preview-support'; + +import ComplexPreview from './ComplexPreview'; + +/** + * @type { import('didi').ModuleDeclaration } + */ +export default { + __depends__: [ PreviewSupportModule ], + __init__: [ 'complexPreview' ], + complexPreview: [ 'type', ComplexPreview ] +}; \ No newline at end of file diff --git a/lib/features/preview-support/PreviewSupport.js b/lib/features/preview-support/PreviewSupport.js index 4355bde95..889e4c64e 100644 --- a/lib/features/preview-support/PreviewSupport.js +++ b/lib/features/preview-support/PreviewSupport.js @@ -118,6 +118,8 @@ PreviewSupport.prototype.addDragger = function(element, group, gfx, className = svgAppend(group, dragger); + svgAttr(dragger, 'data-preview-support-element-id', element.id); + return dragger; }; @@ -141,6 +143,8 @@ PreviewSupport.prototype.addFrame = function(shape, group) { svgAppend(group, frame); + svgAttr(frame, 'data-preview-support-element-id', shape.id); + return frame; }; diff --git a/test/spec/connection-preview/ConnectionPreviewSpec.js b/test/spec/connection-preview/ConnectionPreviewSpec.js index 70e04eda6..76b6b3b29 100755 --- a/test/spec/connection-preview/ConnectionPreviewSpec.js +++ b/test/spec/connection-preview/ConnectionPreviewSpec.js @@ -90,7 +90,7 @@ describe('features/connection-preview', function() { // when connectionPreview.drawPreview(context, true, hints); - var preview = domQuery('.djs-connection-preview', testContainer); + var preview = domQuery('.djs-dragger', testContainer); // then expect(preview).to.exist; @@ -110,7 +110,7 @@ describe('features/connection-preview', function() { // when connectionPreview.drawPreview(context, true, hints); - var preview = domQuery('.djs-connection-preview', testContainer); + var preview = domQuery('.djs-dragger', testContainer); // then expect(preview).to.exist; @@ -131,7 +131,7 @@ describe('features/connection-preview', function() { connectionPreview.drawPreview(context, true, hints); connectionPreview.cleanUp(context); - var preview = domQuery('.djs-connection-preview', testContainer); + var preview = domQuery('.djs-dragger', testContainer); // then expect(preview).not.to.exist; @@ -154,7 +154,7 @@ describe('features/connection-preview', function() { // when connectionPreview.drawPreview(context, false, hints); - var preview = domQuery('.djs-connection-preview', testContainer); + var preview = domQuery('.djs-dragger', testContainer); // then expect(preview).to.exist; @@ -175,7 +175,7 @@ describe('features/connection-preview', function() { // when connectionPreview.drawPreview(context, false, hints); - var preview = domQuery('.djs-connection-preview', testContainer); + var preview = domQuery('.djs-dragger', testContainer); // then expect(preview).to.exist; diff --git a/test/spec/features/complex-preview/ComplexPreviewSpec.js b/test/spec/features/complex-preview/ComplexPreviewSpec.js new file mode 100644 index 000000000..e509dff0c --- /dev/null +++ b/test/spec/features/complex-preview/ComplexPreviewSpec.js @@ -0,0 +1,249 @@ +import { + bootstrapDiagram, + inject +} from 'test/TestHelper'; + +import complexPreviewModule from 'lib/features/complex-preview'; +import modelingModule from 'lib/features/modeling'; +import rendererModule from '../preview-support/renderer'; + +import { + query as domQuery, + queryAll as domQueryAll +} from 'min-dom'; + +var testModules = [ + complexPreviewModule, + modelingModule, + rendererModule +]; + + +describe('features/complex-preview', function() { + + var root, + shape1, + shape2, + connection, + newShape, + newConnection; + + function setupDiagram(elementFactory, canvas) { + root = elementFactory.createRoot({ + id: 'root' + }); + + canvas.setRootElement(root); + + shape1 = elementFactory.createShape({ + id: 'shape1', + x: 0, + y: 0, + width: 100, + height: 100 + }); + + canvas.addShape(shape1, root); + + shape2 = elementFactory.createShape({ + id: 'shape2', + x: 200, + y: 0, + width: 100, + height: 100 + }); + + canvas.addShape(shape2, root); + + connection = elementFactory.createConnection({ + id: 'connection', + source: shape1, + target: shape2, + waypoints: [ + { x: 100, y: 50 }, + { x: 200, y: 50 } + ], + marker: { + start: true, + end: true + } + }); + + canvas.addConnection(connection, root); + + newShape = elementFactory.createShape({ + id: 'newShape', + x: 400, + y: 0, + width: 100, + height: 100 + }); + + newConnection = elementFactory.createConnection({ + id: 'newConnection', + source: shape2, + target: newShape, + waypoints: [ + { x: 300, y: 50 }, + { x: 400, y: 50 } + ], + marker: { + start: true, + end: true + } + }); + } + + + beforeEach(bootstrapDiagram({ + modules: testModules + })); + + beforeEach(inject(setupDiagram)); + + + it('should create preview for created shapes and connections', inject(function(canvas, complexPreview) { + + // when + complexPreview.create({ + created: [ + newConnection, + newShape + ] + }); + + // then + const layer = canvas.getLayer('complex-preview'); + + expect(layer).to.exist; + + expect(queryPreview('newConnection', layer)).to.have.length(1); + expect(queryPreview('newShape', layer)).to.have.length(1); + })); + + + it('should create preview for moved shapes and connections', inject(function(canvas, complexPreview) { + + // when + complexPreview.create({ + moved: [ + { + element: shape1, + delta: { x: 100, y: 100 } + }, + { + element: shape2, + delta: { x: 100, y: 100 } + }, + { + element: connection, + delta: { x: 100, y: 100 } + } + ] + }); + + // then + const layer = canvas.getLayer('complex-preview'); + + expect(layer).to.exist; + + expect(queryPreview('connection', layer)).to.have.length(2); + expect(queryPreview('shape1', layer)).to.have.length(2); + expect(queryPreview('shape2', layer)).to.have.length(2); + })); + + + it('should create preview for removed shapes and connections', inject(function(canvas, complexPreview) { + + // when + complexPreview.create({ + removed: [ + shape2, + connection + ] + }); + + // then + const layer = canvas.getLayer('complex-preview'); + + expect(layer).to.exist; + + expect(queryPreview('connection', layer)).to.have.length(1); + expect(queryPreview('shape2', layer)).to.have.length(1); + })); + + + it('should create preview for resized shapes', inject(function(canvas, complexPreview) { + + // when + complexPreview.create({ + resized: [ + { + shape: shape2, + bounds: { x: 200, y: 0, width: 200, height: 200 } + } + ] + }); + + // then + const layer = canvas.getLayer('complex-preview'); + + expect(layer).to.exist; + + expect(queryPreview('shape2', layer)).to.have.length(2); + })); + + + it('should clone markers', inject(function(canvas, complexPreview) { + + // when + complexPreview.create({ + moved: [ + { + element: shape1, + delta: { x: 100, y: 100 } + }, + { + element: shape2, + delta: { x: 100, y: 100 } + }, + { + element: connection, + delta: { x: 100, y: 100 } + } + ] + }); + + // then + expect(domQueryAll('marker.djs-dragging', canvas.getContainer())).to.have.length(2); + + expect(domQuery('marker#marker-start-djs-dragging-clone', canvas.getContainer())).to.exist; + expect(domQuery('marker#marker-end-djs-dragging-clone', canvas.getContainer())).to.exist; + })); + + + it('should clean up preview', inject(function(canvas, complexPreview) { + + // given + complexPreview.create({ + created: [ + newShape + ] + }); + + // when + complexPreview.cleanUp(); + + // then + const layer = canvas.getLayer('complex-preview'); + + expect(layer).to.exist; + + expect(layer.childNodes).to.have.length(0); + })); + +}); + +function queryPreview(id, layer) { + return domQueryAll('[data-preview-support-element-id="' + id + '"]', layer); +} \ No newline at end of file diff --git a/test/spec/features/move/MovePreviewSpec.js b/test/spec/features/move/MovePreviewSpec.js index 472b4c67c..d924b0860 100644 --- a/test/spec/features/move/MovePreviewSpec.js +++ b/test/spec/features/move/MovePreviewSpec.js @@ -11,7 +11,7 @@ import modelingModule from 'lib/features/modeling'; import moveModule from 'lib/features/move'; import attachSupportModule from 'lib/features/attach-support'; import rulesModule from './rules'; -import rendererModule from './renderer'; +import rendererModule from '../preview-support/renderer'; import { query as domQuery, @@ -584,13 +584,13 @@ describe('features/move - MovePreview', function() { // then var container = canvas.getContainer(); - var clonedMarkers = domQueryAll('marker.djs-dragger-marker', container); + var clonedMarkers = domQueryAll('marker.djs-dragger', container); expect(clonedMarkers).to.have.length(3); - var markerStartClone = domQuery('marker#marker-start-clone', container), - markerMidClone = domQuery('marker#marker-mid-clone', container), - markerEndClone = domQuery('marker#marker-end-clone', container); + var markerStartClone = domQuery('marker#marker-start-djs-dragger-clone', container), + markerMidClone = domQuery('marker#marker-mid-djs-dragger-clone', container), + markerEndClone = domQuery('marker#marker-end-djs-dragger-clone', container); expect(markerStartClone).to.exist; expect(markerMidClone).to.exist; diff --git a/test/spec/features/move/renderer/MarkerRenderer.js b/test/spec/features/preview-support/renderer/MarkerRenderer.js similarity index 94% rename from test/spec/features/move/renderer/MarkerRenderer.js rename to test/spec/features/preview-support/renderer/MarkerRenderer.js index 8f45816a1..95b6d3bf2 100644 --- a/test/spec/features/move/renderer/MarkerRenderer.js +++ b/test/spec/features/preview-support/renderer/MarkerRenderer.js @@ -1,141 +1,141 @@ -import inherits from 'inherits-browser'; - -import { - append as svgAppend, - attr as svgAttr, - create as svgCreate -} from 'tiny-svg'; - -import { - query as domQuery -} from 'min-dom'; - -import DefaultRenderer from 'lib/draw/DefaultRenderer'; - -import { - createLine -} from 'lib//util/RenderUtil'; - -/** - * @typedef {import('../../model').Connection} Connection - */ - -var HIGH_PRIORITY = 3000; - -var CONNECTION_STYLE = { - fill: 'none', - stroke: 'fuchsia', - strokeWidth: 5 -}; - -var MARKER_TYPES = [ - 'marker-start', - 'marker-mid', - 'marker-end' -]; - -/** - * A renderer that can render markers. - */ -export default function MarkerRenderer(canvas, eventBus, styles) { - DefaultRenderer.call(this, eventBus, styles, HIGH_PRIORITY); - - this._canvas = canvas; - - this._markers = {}; -} - -inherits(MarkerRenderer, DefaultRenderer); - -MarkerRenderer.$inject = [ - 'canvas', - 'eventBus', - 'styles' -]; - -MarkerRenderer.prototype.canRender = function() { - return true; -}; - -MarkerRenderer.prototype.drawConnection = function(parentGfx, connection) { - var line = createLine(connection.waypoints, CONNECTION_STYLE); - - svgAppend(parentGfx, line); - - var self = this; - - MARKER_TYPES.forEach(function(markerType) { - if (hasMarker(connection, markerType)) { - self.addMarker(line, markerType); - } - }); - - return line; -}; - -MarkerRenderer.prototype.addMarker = function(gfx, markerType) { - var marker = this._markers[ markerType ], - defs; - - if (!marker) { - marker = this._markers[ markerType ] = svgCreate('marker'); - - marker.id = markerType; - - svgAttr(marker, { - refX: 5, - refY: 5, - viewBox: '0 0 10 10' - }); - - var circle = svgCreate('circle'); - - svgAttr(circle, { - cx: 5, - cy: 5, - fill: 'fuchsia', - r: 5 - }); - - svgAppend(marker, circle); - - defs = domQuery('defs', this._canvas._svg); - - if (!defs) { - defs = svgCreate('defs'); - - svgAppend(this._canvas._svg, defs); - } - - svgAppend(defs, marker); - } - - var reference = idToReference(marker.id); - - svgAttr(gfx, markerType, reference); -}; - -// helpers ////////// - -/** - * Get functional IRI reference for given ID of fragment within current document. - * - * @param {string} id - * - * @return {string} - */ -function idToReference(id) { - return 'url(#' + id + ')'; -} - -/** - * Check wether given connection has marker of given type. - * - * @param {Connection} connection - * @param {string} markerType - * - * @return {boolean} - */ -function hasMarker(connection, markerType) { - return connection.marker && connection.marker[ markerType.split('-').pop() ]; +import inherits from 'inherits-browser'; + +import { + append as svgAppend, + attr as svgAttr, + create as svgCreate +} from 'tiny-svg'; + +import { + query as domQuery +} from 'min-dom'; + +import DefaultRenderer from 'lib/draw/DefaultRenderer'; + +import { + createLine +} from 'lib//util/RenderUtil'; + +/** + * @typedef {import('../../model').Connection} Connection + */ + +var HIGH_PRIORITY = 3000; + +var CONNECTION_STYLE = { + fill: 'none', + stroke: 'fuchsia', + strokeWidth: 5 +}; + +var MARKER_TYPES = [ + 'marker-start', + 'marker-mid', + 'marker-end' +]; + +/** + * A renderer that can render markers. + */ +export default function MarkerRenderer(canvas, eventBus, styles) { + DefaultRenderer.call(this, eventBus, styles, HIGH_PRIORITY); + + this._canvas = canvas; + + this._markers = {}; +} + +inherits(MarkerRenderer, DefaultRenderer); + +MarkerRenderer.$inject = [ + 'canvas', + 'eventBus', + 'styles' +]; + +MarkerRenderer.prototype.canRender = function() { + return true; +}; + +MarkerRenderer.prototype.drawConnection = function(parentGfx, connection) { + var line = createLine(connection.waypoints, CONNECTION_STYLE); + + svgAppend(parentGfx, line); + + var self = this; + + MARKER_TYPES.forEach(function(markerType) { + if (hasMarker(connection, markerType)) { + self.addMarker(line, markerType); + } + }); + + return line; +}; + +MarkerRenderer.prototype.addMarker = function(gfx, markerType) { + var marker = this._markers[ markerType ], + defs; + + if (!marker) { + marker = this._markers[ markerType ] = svgCreate('marker'); + + marker.id = markerType; + + svgAttr(marker, { + refX: 5, + refY: 5, + viewBox: '0 0 10 10' + }); + + var circle = svgCreate('circle'); + + svgAttr(circle, { + cx: 5, + cy: 5, + fill: 'fuchsia', + r: 5 + }); + + svgAppend(marker, circle); + + defs = domQuery('defs', this._canvas._svg); + + if (!defs) { + defs = svgCreate('defs'); + + svgAppend(this._canvas._svg, defs); + } + + svgAppend(defs, marker); + } + + var reference = idToReference(marker.id); + + svgAttr(gfx, markerType, reference); +}; + +// helpers ////////// + +/** + * Get functional IRI reference for given ID of fragment within current document. + * + * @param {string} id + * + * @return {string} + */ +function idToReference(id) { + return 'url(#' + id + ')'; +} + +/** + * Check wether given connection has marker of given type. + * + * @param {Connection} connection + * @param {string} markerType + * + * @return {boolean} + */ +function hasMarker(connection, markerType) { + return connection.marker && connection.marker[ markerType.split('-').pop() ]; } \ No newline at end of file diff --git a/test/spec/features/move/renderer/index.js b/test/spec/features/preview-support/renderer/index.js similarity index 96% rename from test/spec/features/move/renderer/index.js rename to test/spec/features/preview-support/renderer/index.js index 79c5a6842..80b3cb1d8 100644 --- a/test/spec/features/move/renderer/index.js +++ b/test/spec/features/preview-support/renderer/index.js @@ -1,6 +1,6 @@ -import MarkerRenderer from './MarkerRenderer'; - -export default { - __init__: [ 'defaultRenderer' ], - defaultRenderer: [ 'type', MarkerRenderer ] +import MarkerRenderer from './MarkerRenderer'; + +export default { + __init__: [ 'defaultRenderer' ], + defaultRenderer: [ 'type', MarkerRenderer ] }; \ No newline at end of file