From bdbe5b25132c0439f556199219b379daab97006a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Pr=C3=BCsse?= Date: Mon, 21 May 2018 15:19:22 -0300 Subject: [PATCH 1/2] Add zoom in/out/reset and fit option --- qmxgraph/api.py | 32 ++++++++++++++++ qmxgraph/page/api.js | 46 +++++++++++++++++++++-- qmxgraph/page/css/graph.css | 1 + qmxgraph/page/graphs.js | 16 ++++++++ qmxgraph/page/utils.js | 17 --------- qmxgraph/widget.py | 9 ++++- tests/test_js_graph.py | 74 ++++++++++++++++++------------------- 7 files changed, 135 insertions(+), 60 deletions(-) diff --git a/qmxgraph/api.py b/qmxgraph/api.py index c7597fc..8c48646 100644 --- a/qmxgraph/api.py +++ b/qmxgraph/api.py @@ -339,6 +339,38 @@ def is_visible(self, cell_id): """ return self.call_api('isVisible', cell_id) + def zoom_in(self): + """ + Zoom in the graph. + """ + return self.call_api('zoomIn') + + def zoom_out(self): + """ + Zoom out the graph. + """ + return self.call_api('zoomOut') + + def reset_zoom(self): + """ + Reset graph's zoom. + """ + return self.call_api('resetZoom') + + def fit(self): + """ + Rescale the graph to fit in the container. + """ + return self.call_api('fit') + + def get_zoom_scale(self): + """ + Return the current scale (zoom). + + :rtype: float + """ + return self.call_api('getZoomScale') + def set_selected_cells(self, cell_ids): """ Select the cells with the given ids. diff --git a/qmxgraph/page/api.js b/qmxgraph/page/api.js index be981cf..5eb4b8e 100644 --- a/qmxgraph/page/api.js +++ b/qmxgraph/page/api.js @@ -77,8 +77,6 @@ graphs.Api.prototype.insertVertex = function insertVertex ( style ); - graphs.utils.resizeContainerOnDemand(graph, vertex); - return vertex.getId(); }; @@ -134,7 +132,6 @@ graphs.Api.prototype.insertPort = function insertPort ( } finally { model.endUpdate(); } - graphs.utils.resizeContainerOnDemand(graph, port); }; /** @@ -369,7 +366,6 @@ graphs.Api.prototype.insertTable = function insertTable ( model.endUpdate(); } - graphs.utils.resizeContainerOnDemand(graph, table); return table.getId(); }; @@ -706,6 +702,48 @@ graphs.Api.prototype.resizeContainer = function resizeContainer (width, height) this._graphEditor.graph.doResizeContainer(width, height); }; +/** + * Zoom in the graph. + */ +graphs.Api.prototype.zoomIn = function zoomIn () { + "use strict"; + this._graphEditor.graph.zoomIn(); +}; + +/** + * Zoom out the graph. + */ +graphs.Api.prototype.zoomOut = function zoomOut () { + "use strict"; + this._graphEditor.graph.zoomOut(); +}; + +/** + * Return the current scale (zoom). + * + * @returns {number} + */ +graphs.Api.prototype.getZoomScale = function getZoomScale () { + "use strict"; + return this._graphEditor.graph.view.scale; +}; + +/** + * Reset graph's zoom. + */ +graphs.Api.prototype.resetZoom = function resetZoom () { + "use strict"; + this._graphEditor.graph.zoomActual(); +}; + +/** + * Rescale the graph to fit in the container. + */ +graphs.Api.prototype.fit = function fit () { + "use strict"; + this._graphEditor.graph.fit(10); +}; + /** * Remove cells from graph. * diff --git a/qmxgraph/page/css/graph.css b/qmxgraph/page/css/graph.css index 0e8cbe9..294351e 100644 --- a/qmxgraph/page/css/graph.css +++ b/qmxgraph/page/css/graph.css @@ -5,6 +5,7 @@ html, body { .graph { background:url('../images/grid.gif'); + overflow: hidden; } .graph.hide-bg { diff --git a/qmxgraph/page/graphs.js b/qmxgraph/page/graphs.js index 66b0109..10f29ca 100644 --- a/qmxgraph/page/graphs.js +++ b/qmxgraph/page/graphs.js @@ -431,6 +431,22 @@ graphs.createGraph = function createGraph (container, options, styles) { }; graph.addListener(mxEvent.REFRESH, onRefresh); + // * Adds mouse wheel handling for zoom + mxEvent.addMouseWheelListener(function(evt, up) { + // - `up = true` direction: + // Moves the viewport closer to the graph; + // When browsing a web page the vertical scrollbar will move up; + // - `up = false` direction: + // Moves the viewport away from the graph; + // When browsing a web page the vertical scrollbar will move down; + if (up) { + graph.zoomIn(); + } else { + graph.zoomOut(); + } + mxEvent.consume(evt); + }); + // DEBUG ------------------------------------------------------------------- graph.container.addEventListener( diff --git a/qmxgraph/page/utils.js b/qmxgraph/page/utils.js index df24b62..b9b251c 100644 --- a/qmxgraph/page/utils.js +++ b/qmxgraph/page/utils.js @@ -272,23 +272,6 @@ graphs.utils.createTableElement = function createTableElement (contents, title) return table; }; -/** - * In case cell is added too close to graph boundaries resize the container. - * - * @param {mxGraph} graph A graph. - * @param {mxCell} cell A cell in graph. - */ -graphs.utils.resizeContainerOnDemand = function resizeContainerOnDemand (graph, cell) { - "use strict"; - - var bbox = graph.getBoundingBox([cell]); - var containerWidth = Math.max( - graph.container.offsetWidth, bbox.x + bbox.width); - var containerHeight = Math.max( - graph.container.offsetHeight, bbox.y + bbox.height); - graph.doResizeContainer(containerWidth, containerHeight); -}; - /** * Replace html "unsafe" characters on a given string. * From https://stackoverflow.com/a/4835406/783219 diff --git a/qmxgraph/widget.py b/qmxgraph/widget.py index 5a3d2e1..4bb4e0c 100644 --- a/qmxgraph/widget.py +++ b/qmxgraph/widget.py @@ -423,10 +423,15 @@ def _on_drop(self, event): if version in (1, 2): vertices = parsed.get('vertices', []) + scale = self.api.get_zoom_scale() for v in vertices: + # place vertices with an offset so their center falls + # in the event point. + vertex_x = x + (v['dx'] - v['width'] * 0.5) * scale + vertex_y = y + (v['dy'] - v['height'] * 0.5) * scale self.api.insert_vertex( - x=x + v['dx'] - v['width'] // 2, - y=y + v['dy'] - v['height'] // 2, + x=vertex_x, + y=vertex_y, width=v['width'], height=v['height'], label=v['label'], diff --git a/tests/test_js_graph.py b/tests/test_js_graph.py index 41d7d1f..4447d95 100644 --- a/tests/test_js_graph.py +++ b/tests/test_js_graph.py @@ -70,20 +70,6 @@ def test_insert_vertex_with_style(graph_cases): assert vertex.get_attribute('fill') != default -def test_insert_vertex_close_to_boundaries(graph_cases): - """ - :type graph_cases: qmxgraph.tests.conftest.GraphCaseFactory - """ - graph = graph_cases('empty') - - width, height = graph.get_container_size() - - assert graph.eval_js_function( - "api.insertVertex", width - 10, height - 10, 25, 25, 'label') - - assert graph.get_container_size() == (width + 16, height + 16) - - @pytest.mark.parametrize( 'mode', [ @@ -501,29 +487,6 @@ def test_table_with_image(graph_cases): assert image.get_attribute('src').endswith('some-image-path') -def test_insert_table_close_to_boundaries(graph_cases): - """ - :type graph_cases: qmxgraph.tests.conftest.GraphCaseFactory - """ - graph = graph_cases('empty') - - width, height = graph.get_container_size() - - contents = { # graphs.utils.TableDescription - 'contents': [ - # graphs.utils.TableRowDescription's - {'contents': ['alpha', '100']}, - {'contents': ['beta', '200']}, - {'contents': ['gamma', '300']}, - ] - } - assert graph.eval_js_function( - "api.insertTable", width - 10, height - 10, 0, contents, 'title') - - assert graph.get_container_size() == \ - fix_table_size(width + 79, height + 85) - - def test_update_table(graph_cases): """ :type graph_cases: qmxgraph.tests.conftest.GraphCaseFactory @@ -1228,6 +1191,43 @@ def test_set_popup_menu_handler(graph_cases): [[vertex_id, x, y]] +@pytest.mark.parametrize('action, expected_scale', [('zoomIn', 1.2), ('zoomOut', 0.83)]) +def test_zoom(graph_cases, action, expected_scale): + """ + :type graph_cases: qmxgraph.tests.conftest.GraphCaseFactory + :type action: str + :type expected_scale: float + """ + graph = graph_cases('2v_1e') + obtained_scale = graph.eval_js_function('api.getZoomScale') + assert obtained_scale == 1.0 + + graph.eval_js_function('api.{}'.format(action)) + obtained_scale = graph.eval_js_function('api.getZoomScale') + assert obtained_scale == expected_scale + + graph.eval_js_function('api.resetZoom') + obtained_scale = graph.eval_js_function('api.getZoomScale') + assert obtained_scale == 1.0 + + +@pytest.mark.parametrize('action', [None, 'zoomIn', 'zoomOut']) +def test_fit(graph_cases, action): + """ + :type graph_cases: qmxgraph.tests.conftest.GraphCaseFactory + :type action: Optional[str] + """ + graph = graph_cases('2v_1e') + obtained_scale = graph.eval_js_function('api.getZoomScale') + assert obtained_scale == 1.0 + if action is not None: + graph.eval_js_function('api.{}'.format(action)) + + graph.eval_js_function('api.fit') + obtained_scale = graph.eval_js_function('api.getZoomScale') + assert obtained_scale == pytest.approx(3.14, abs=2) + + def test_get_edge_terminals(graph_cases): """ :type graph_cases: qmxgraph.tests.conftest.GraphCaseFactory From 312eca90122bb15ac85e1150aca169fb2839328a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Pr=C3=BCsse?= Date: Tue, 22 May 2018 16:53:28 -0300 Subject: [PATCH 2/2] Allow the scale and translation to be saved/loaded in a simple function call Having them in a single function call will abstract the order in which the zoom and translations are applied, --- qmxgraph/api.py | 24 ++++++++++++ qmxgraph/page/api.js | 37 +++++++++++++++++- tests/test_js_graph.py | 85 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/qmxgraph/api.py b/qmxgraph/api.py index 8c48646..cf13e29 100644 --- a/qmxgraph/api.py +++ b/qmxgraph/api.py @@ -371,6 +371,30 @@ def get_zoom_scale(self): """ return self.call_api('getZoomScale') + def get_scale_and_translation(self): + """ + Get the current scale and translation. + + :rtype: Tuple[float, float, float] + :return: Respectively the values of graph scale, the translation + along the x axis, and the translation along the y axis. The + three values returned by this function is suitable to be + supplied to `` to set the scale and translation to a previous + value. + """ + return tuple(self.call_api('getScaleAndTranslation')) + + def set_scale_and_translation(self, scale, x, y): + """ + Set the scale and translation. + + :param float scale: The new graph's scale (1 = 100%). + :param float x: The new graph's translation along the X axis + (0 = origin). + :param float y: The new graph's scale along the Y axis (0 = origin}. + """ + return self.call_api('setScaleAndTranslation', scale, x, y) + def set_selected_cells(self, cell_ids): """ Select the cells with the given ids. diff --git a/qmxgraph/page/api.js b/qmxgraph/page/api.js index 5eb4b8e..ed13001 100644 --- a/qmxgraph/page/api.js +++ b/qmxgraph/page/api.js @@ -707,6 +707,7 @@ graphs.Api.prototype.resizeContainer = function resizeContainer (width, height) */ graphs.Api.prototype.zoomIn = function zoomIn () { "use strict"; + this._graphEditor.graph.zoomIn(); }; @@ -715,6 +716,7 @@ graphs.Api.prototype.zoomIn = function zoomIn () { */ graphs.Api.prototype.zoomOut = function zoomOut () { "use strict"; + this._graphEditor.graph.zoomOut(); }; @@ -725,7 +727,8 @@ graphs.Api.prototype.zoomOut = function zoomOut () { */ graphs.Api.prototype.getZoomScale = function getZoomScale () { "use strict"; - return this._graphEditor.graph.view.scale; + + return this._graphEditor.graph.view.getScale(); }; /** @@ -733,14 +736,46 @@ graphs.Api.prototype.getZoomScale = function getZoomScale () { */ graphs.Api.prototype.resetZoom = function resetZoom () { "use strict"; + this._graphEditor.graph.zoomActual(); }; +/** + * Get the current scale and translation. + * + * @returns {number[]} The graph scale, the translation along the x axis, and the translation + * along the y axis. The three values returned by this function is suitable to be supplied to + * {@link graphs.Api#setScaleAndTranslation} to set the scale and translation to a previous value. + */ +graphs.Api.prototype.getScaleAndTranslation = function getScaleAndTranslation () { + "use strict"; + + var graph = this._graphEditor.graph; + var scale = graph.view.getScale(); + var translate = graph.view.getTranslate(); + return [scale, translate.x, translate.y]; +}; + +/** + * Set the scale and translation. + * + * @param {number} scale The new graph's scale (1 = 100%). + * @param {number} x The new graph's translation along the X axis (0 = origin). + * @param {number} y The new graph's scale along the Y axis (0 = origin}. + */ +graphs.Api.prototype.setScaleAndTranslation = function setScaleAndTranslation (scale, x, y) { + "use strict"; + + var view = this._graphEditor.graph.getView(); + view.scaleAndTranslate(scale, x, y); +}; + /** * Rescale the graph to fit in the container. */ graphs.Api.prototype.fit = function fit () { "use strict"; + this._graphEditor.graph.fit(10); }; diff --git a/tests/test_js_graph.py b/tests/test_js_graph.py index 4447d95..c945556 100644 --- a/tests/test_js_graph.py +++ b/tests/test_js_graph.py @@ -1191,7 +1191,10 @@ def test_set_popup_menu_handler(graph_cases): [[vertex_id, x, y]] -@pytest.mark.parametrize('action, expected_scale', [('zoomIn', 1.2), ('zoomOut', 0.83)]) +@pytest.mark.parametrize( + 'action, expected_scale', + [('zoomIn', 1.2), ('zoomOut', 0.83)], +) def test_zoom(graph_cases, action, expected_scale): """ :type graph_cases: qmxgraph.tests.conftest.GraphCaseFactory @@ -1211,6 +1214,86 @@ def test_zoom(graph_cases, action, expected_scale): assert obtained_scale == 1.0 +@pytest.mark.xfail( + 'sys.platform != "win32"', + reason='need investigate differences between linux and windows', +) +def test_set_scale_and_translation(graph_cases): + """ + :type graph_cases: qmxgraph.tests.conftest.GraphCaseFactory + """ + graph = graph_cases('1v') + + ini_scale, ini_x, ini_y = graph.eval_js_function( + 'api.getScaleAndTranslation') + assert (ini_scale, ini_x, ini_y) == (1, 0, 0) + + from selenium.webdriver.common.actions.mouse_button import MouseButton + from selenium.webdriver.remote.command import Command + + class MyActionChains(ActionChains): + def click_and_hold_right(self, on_element=None): + if self._driver.w3c: + if on_element: + self.w3c_actions.pointer_action.move_to(on_element) + self.w3c_actions.pointer_action.pointer_down( + MouseButton.RIGHT) + self.w3c_actions.key_action.pause() + if on_element: + self.w3c_actions.key_action.pause() + else: + if on_element: + self.move_to_element(on_element) + self._actions.append(lambda: self._driver.execute( + Command.MOUSE_DOWN, {'button': 2})) + return self + + def release_right(self, on_element=None): + if on_element: + self.move_to_element(on_element) + if self._driver.w3c: + self.w3c_actions.pointer_action.pointer_up(MouseButton.RIGHT) + self.w3c_actions.key_action.pause() + else: + self._actions.append(lambda: self._driver.execute( + Command.MOUSE_UP, {'button': 2})) + return self + + vertex = graph.get_vertex() + w, h = graph.get_vertex_size(vertex) + + def ScaleAndTranslateGraph(): + graph.eval_js_function('api.zoomIn') + + actions = MyActionChains(graph.selenium) + actions.move_to_element_with_offset(vertex, w * 2, h * 2) + actions.click_and_hold_right() + actions.move_by_offset(30, 100) + actions.release_right() # mxgraph does some extra work on release. + actions.perform() + + graph.eval_js_function('api.zoomIn') + + ScaleAndTranslateGraph() + saved_scale, saved_x, saved_y = graph.eval_js_function( + 'api.getScaleAndTranslation') + assert saved_scale == pytest.approx(1.44, abs=2) + assert saved_x == pytest.approx(-36.11, abs=2) + assert saved_y == pytest.approx(60.42, abs=2) + + ScaleAndTranslateGraph() + new_scale, new_x, new_y = graph.eval_js_function( + 'api.getScaleAndTranslation') + assert new_scale == pytest.approx(2.08, abs=2) + assert new_x == pytest.approx(-61.50, abs=2) + assert new_y == pytest.approx(97.28, abs=2) + + graph.eval_js_function( + 'api.setScaleAndTranslation', saved_scale, saved_x, saved_y) + scale, x, y = graph.eval_js_function('api.getScaleAndTranslation') + assert (scale, x, y) == (saved_scale, saved_x, saved_y) + + @pytest.mark.parametrize('action', [None, 'zoomIn', 'zoomOut']) def test_fit(graph_cases, action): """