├── .circleci └── config.yml ├── .gitignore ├── .nycrc ├── @hpcc-js ├── CHANGELOG.md ├── HOWTO ├── LICENSE ├── Makefile ├── README.md ├── bin ├── generate-graphviz-version.js ├── generate-nodes.js └── generate-versions.py ├── codecov.yml ├── cypress.config.js ├── cypress ├── e2e │ ├── browser_save_and_open.spec.js │ ├── draw_edge.spec.js │ ├── export_and_import_as_url.spec.js │ ├── fullscreen_graph.spec.js │ ├── github.spec.js │ ├── help.spec.js │ ├── insert_node.spec.js │ ├── main_menu.spec.js │ ├── pan_and_zoom.spec.js │ ├── rendering.spec.js │ ├── select_delete.spec.js │ ├── select_deselect.spec.js │ ├── settings.spec.js │ ├── text_editor.spec.js │ ├── transition.spec.js │ └── undo_redo.spec.js └── support │ ├── commands.js │ └── e2e.js ├── package-lock.json ├── package.json ├── public ├── @hpcc-js │ └── wasm │ │ └── dist │ │ └── graphviz.umd.js ├── GraphvizLogo.png └── index.html └── src ├── AboutDialog.js ├── ButtonAppBar.js ├── ColorPicker.js ├── DoYouWantToDeleteDialog.js ├── DoYouWantToReplaceItDialog.js ├── DotSrcPreview.js ├── ExportAsSvgDialog.js ├── ExportAsUrlDialog.js ├── FormatDrawer.js ├── GitHubIcon.js ├── Graph.js ├── HelpMenu.js ├── InsertPanels.js ├── KeyboardShortcutsDialog.js ├── MainMenu.js ├── MouseOperationsDialog.js ├── OpenFromBrowserDialog.js ├── SaveAsToBrowserDialog.js ├── SettingsDialog.js ├── SvgPreview.js ├── TextEditor.js ├── UpdatedSnackbar.js ├── dot.js ├── dot.test.js ├── dotGrammar.pegjs ├── index.js ├── pages └── index.js ├── test-utils ├── polyfillElement.js ├── polyfillFetch.js ├── polyfillSVGElement.js └── polyfillXMLSerializer.js └── withRoot.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: cimg/node:16.20 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "package.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: npm install 30 | 31 | - save_cache: 32 | paths: 33 | - node_modules 34 | key: v1-dependencies-{{ checksum "package.json" }} 35 | 36 | # run tests! 37 | - run: make && yarn test:coverage && yarn run codecov 38 | integration-test: 39 | docker: 40 | - image: cypress/browsers:node-18.16.1-chrome-114.0.5735.133-1-ff-114.0.2-edge-114.0.1823.51-1 41 | environment: 42 | ## this enables colors in the output 43 | TERM: xterm 44 | working_directory: ~/app 45 | steps: 46 | - checkout 47 | - restore_cache: 48 | keys: 49 | - v1-deps-{{ .Branch }}-{{ checksum "package.json" }} 50 | - v1-deps-{{ .Branch }} 51 | - v1-deps 52 | - run: 53 | name: Install Dependencies 54 | no_output_timeout: 30m 55 | command: npm ci 56 | 57 | - save_cache: 58 | key: v1-deps-{{ .Branch }}-{{ checksum "package.json" }} 59 | # cache NPM modules and the folder with the Cypress binary 60 | paths: 61 | - ~/.npm 62 | - ~/.cache 63 | - run: 64 | command: | 65 | apt update 66 | apt install make 67 | make 68 | npm run start:coverage & 69 | npm exec wait-on http://localhost:3000/ 70 | npm run integration-test 71 | yarn run codecov 72 | workflows: 73 | version: 2 74 | build-and-integration-test: 75 | jobs: 76 | - build 77 | - integration-test 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | yarn.lock 24 | 25 | .vscode 26 | 27 | # generated files 28 | src/shapes.js 29 | src/dotParser.js 30 | src/graphviz-versions.json 31 | src/graphvizVersion.js 32 | src/versions.json 33 | 34 | coverage-cypress 35 | coverage-jest 36 | 37 | /graphviz 38 | dotfiles.txt 39 | 40 | cypress/screenshots 41 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "src/dotParser.js", 4 | "src/shapes.js" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /@hpcc-js: -------------------------------------------------------------------------------- 1 | public/@hpcc-js -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ### Fixed 10 | 11 | * Clicking the fullscreen button steals keyboard focus away from the canvas #300 12 | * Running `make` on Windows fails. 13 | * Unable to create generated files using make with parallel job execution #295 14 | 15 | ## [1.3.0] - 2024-08-20 16 | 17 | ### Changed 18 | * Updated [Graphviz](https://graphviz.org/) from version 19 | [11.0.0](https://gitlab.com/graphviz/graphviz/-/blob/main/CHANGELOG.md#1100--2024-04-28) 20 | to version 21 | [12.1.0](https://gitlab.com/graphviz/graphviz/-/blob/main/CHANGELOG.md#1210--2024-08-12) 22 | through 23 | [d3-graphviz](https://github.com/magjac/d3-graphviz) 24 | version 25 | [5.6.0](https://github.com/magjac/d3-graphviz/blob/master/CHANGELOG.md#560--2024-08-18). 26 | 27 | ## [1.2.0] - 2024-05-25 28 | 29 | ### Changed 30 | * Updated [Graphviz](https://graphviz.org/) from version 31 | [10.0.1](https://gitlab.com/graphviz/graphviz/-/blob/main/CHANGELOG.md#1001--2024-02-11) 32 | to version 33 | [11.0.0](https://gitlab.com/graphviz/graphviz/-/blob/main/CHANGELOG.md#1100--2024-04-28) 34 | through 35 | [d3-graphviz](https://github.com/magjac/d3-graphviz) 36 | version 37 | [5.4.0](https://github.com/magjac/d3-graphviz/blob/master/CHANGELOG.md#540--2024-05-05). 38 | 39 | ### Fixed 40 | * Drawing an edge always inserts a directed edge even if the graph is not a directed graph, which results in "syntax error" #237 41 | 42 | ## [1.1.0] - 2024-02-11 43 | 44 | ### Changed 45 | * Updated [Graphviz](https://graphviz.org/) from version 46 | [9.0.0](https://gitlab.com/graphviz/graphviz/-/blob/main/CHANGELOG.md?ref_type=heads#900-2023-09-11) 47 | to version 48 | [10.0.1](https://gitlab.com/graphviz/graphviz/-/blob/main/CHANGELOG.md?ref_type=heads#1001-2024-02-11) 49 | through 50 | [d3-graphviz](https://github.com/magjac/d3-graphviz) 51 | version 52 | [5.3.0](https://github.com/magjac/d3-graphviz/blob/master/CHANGELOG.md#530--2024-02-11). 53 | 54 | ### Fixed 55 | * Entering a node named 'constructor' causes error and no graph rendered #265 56 | * Having a node called toString hard crashes the editor #272 57 | 58 | ## [1.0.0] - 2024-01-21 59 | 60 | ### Added 61 | * Show graph in fullscreen mode by clicking the fullscreen button or pressing the 'f' key in the graph. 62 | * Editor search box. Opens with Ctrl-F. 63 | 64 | ### Changed 65 | * Updated [Graphviz](https://graphviz.org/) from version [2.50.0](https://gitlab.com/graphviz/graphviz/-/blob/main/CHANGELOG.md?ref_type=heads#2500-2021-12-04) to version [9.0.0](https://gitlab.com/graphviz/graphviz/-/blob/main/CHANGELOG.md?ref_type=heads#900-2023-09-11) through [@hpcc-js/wasm](https://github.com/hpcc-systems/hpcc-js-wasm) version [2.14.1](https://github.com/hpcc-systems/hpcc-js-wasm/blob/trunk/CHANGELOG.md#2141-2023-10-12), containing a lot of improvements and fixes, including a fix for: 66 | * Failure of arrowhead and arrowtail to respect penwidth ([Graphviz issue #372](https://gitlab.com/graphviz/graphviz/issues/372)) 67 | * New look with blueish primary and greenish secondary color and a toolbar with a Graphviz logo, a blueish grid on white as background and slightly larger text buttons. 68 | * More pronounced highlighting in the DOT source of nodes and edges selected in the graph, using shades of the blueish primary color. 69 | 70 | ### Fixed 71 | * Lost characters when typing fast and DOT parsing errors occur #236 72 | * New nodes created after linking nodes with a space in the label #215 (thanks @ygra) 73 | * Keyboard shortcuts involving the control key in the graph doesn't work in Firefox on Ubuntu when the "Locate Pointer" feature is enabled #260 74 | 75 | ## [0.6.5] - 2022-02-24 76 | 77 | ### Changed 78 | * Add a snackbar showing when the application has been updated and if the underlying Graphviz version has been updated or not. 79 | * Make the version in the about dialog a link to the version in CHANGELOG.md. 80 | * Upgrade d3-graphviz to version 4.1.0 (Graphviz 2.50.0) 81 | * Bundle @hpcc-js/wasm instead of loading from unpkg 82 | * Added "Export as SVG" to main menu (thanks @pRizz). 83 | 84 | ## [0.6.4] - 2020-04-29 85 | ### Fixed 86 | * Drawing edges or inserting nodes does not work in production bundle #139 87 | * Navigating back to the referring page after URL import requires clicking back twice in the browser #155 88 | 89 | ## [0.6.3] - 2020-04-09 90 | ### Changed 91 | * Upgraded [d3-graphviz](https://gitlab.com/magjac/d3-graphviz) to version [3.0.5](https://github.com/magjac/d3-graphviz/blob/master/CHANGELOG.md#305) thereby replacing [Viz.js](https://github.com/mdaines/viz.js/) with [@hpcc-js/wasm](https://github.com/hpcc-systems/hpcc-js-wasm). 92 | * Upgraded [Graphviz](https://gitlab.com/graphviz/graphviz) to version [2.42.4](https://gitlab.com/graphviz/graphviz/-/releases/2.42.4) through [@hpcc-js/wasm](https://github.com/hpcc-systems/hpcc-js-wasm) version [0.3.11](https://github.com/hpcc-systems/hpcc-js-wasm/releases/tag/v0.3.11), including fixes for: 93 | * svg output displays TITLE of %3 if graph had no name ([Graphviz issue #1376](https://gitlab.com/graphviz/graphviz/issues/1376)) 94 | * XML errors in generated SVG when URL attribute contains ampersand (&) ([Graphviz issue #1687](https://gitlab.com/graphviz/graphviz/issues/1687)) 95 | 96 | ### Fixed 97 | * Changing text editor hold-off time in settings has no effect until application is reloaded #128 98 | * Selecting opacity with the opacity slider does not work in the node & edge default format color pickers #125 99 | 100 | ## 0.6.2 101 | Never released 102 | 103 | ## [0.6.1] - 2020-01-03 104 | ### Fixed 105 | * Module not found: Can't resolve './DoYouWantToDeleteDialog'. #93 106 | * Stuck at "Starting the development server". #95 107 | * Exported URL to graph shows the graph correctly, but the new URL is wrong. #97 108 | * Characters are lost in the editor when typing fast. #99 109 | * Selection by dragging the canvas does not work in Firefox. #102 110 | * Ctrl- or Shift-click the canvas deselects already selected components. #107 111 | * Unselected components are not cleared in text editor. #108 112 | 113 | ## [0.6.0] - 2018-10-01 114 | ### Added 115 | * Export as URL. Generates a link to the application with the DOT source code as an URL parameter. 116 | * Specification of the DOT source code through a URL parameter. #69 117 | * Disabling of the undo and redo buttons when there is nothing to undo or redo. 118 | * Allow multiple graphs to be stored in the browser's local storage. #70 119 | * Name and save a graph to local storage 120 | * Open a named graph from local storage 121 | * Allow sorting graphs on name, DOT source and last modification time in the open from browser dialog 122 | * Allow deleting graphs in the open from browser dialog 123 | * Show graph thumbnails and allow preview in the open from browser dialog 124 | * Create new empty graph 125 | * Rename current graph 126 | 127 | ### Fixed 128 | * Ctrl-Y and Ctrl-Z descriptions are missing in the keyboard shortcuts help dialog. #90 129 | * If the DOT source is cleared when an error is indicated in the text editor, the old error message is still displayed. #88 130 | * When the DOT source is cleared in the text editor, the old graph is still visible. #87 131 | * The error button in the text editor might be covered by the highlighting of the current line. #85 132 | * Corrected size of GitHub icon in app bar. 133 | * When inserting a node with default shape by click in the node shape insert panel, the node gets an incorrect shape. #77 134 | * The selection indication in the graph is cleared when a node is inserted. #78 135 | * The graph pane is not focused after inserting a node shape from node shape insert panel. #58 136 | 137 | ## [0.5.0] - 2018-09-19 138 | ### Added 139 | * Display of progress indicator when rendering of graph takes longer than 800 ms. #38 140 | * Indication of focused pane by increasing its elevation, thereby making it cast more shadow. #39 141 | * User configuration of transition duration. #52 142 | * User configuration of tweening precision. #44 143 | * User configuration of path & shape tweening enable/disable. #43 144 | 145 | ### Fixed 146 | * Lost undo/redo history when node or edge format drawer is opened. #53 147 | 148 | ## [0.4.0] - 2018-09-15 149 | ### Added 150 | * User configurable text editor tab size. #41 151 | * User configurable text editor font size. #42 152 | * Undo and redo from the Graph pane throgh Ctrl-Z & Ctrl-Y. #36 153 | * Undo and redo from buttons in the app bar. #37 154 | * Scrolling of text editor error indication into view through a button. #46 155 | * Automatic scrolling of text editor error indication into view. #46 156 | * Highlighting of nodes and edges in the text editor when selected in the graph. #35 157 | 158 | ### Fixed 159 | * When the Settings dialog needs a scroll bar there is one scroll bar for each 160 | section instead of just one for the whole dialog. #45 161 | 162 | ## [0.3.1] - 2018-09-13 163 | ### Fixed 164 | * Error indication in text editor is cleared even though the error is still present. #33 165 | 166 | ## [0.3.0] - 2018-09-13 167 | ### Added 168 | * Support graphical delete of nodes and edges between nodes in arbitrarily formatted DOT source code. #15 169 | * Support graphical insert of nodes and edges in arbitrarily formatted DOT source code. #27 170 | 171 | ### Fixed 172 | * Nodes with quoted node id cannot be deleted. #21 173 | 174 | ## [0.2.1] - 2018-09-11 175 | ### Fixed 176 | * DOT errors are not always indicated in the text editor. #29 177 | * Characters are lost in the editor when typing fast and DOT parsing errors occur. #22 178 | 179 | ## [0.2.0] - 2018-08-29 180 | ### Added 181 | * Selection of all nodes and edges in the graph with Ctrl-A. #13 182 | * Selection of all edges in the graph with Shift-Ctrl-A. #14 183 | * GitHub button in the app bar linking to the repository. #18 184 | * Open source and GitHub text and links in the about dialog. #20 185 | * Description of the application in the about dialog. 186 | * Configurable editor hold-off time in the settings dialog. 187 | 188 | ### Changed 189 | * Improved response time by not attempting to render the graph when the DOT source is incorrect. 190 | * Improved response time by not re-rendering the graph when the DOT source is unchanged. #19 191 | 192 | ## [0.1.0] - 2018-08-24 193 | ### Added 194 | * Cut/Copy-and-paste of nodes within subgraphs. #16 195 | 196 | ### Fixed 197 | * Keyboard input is targeted to the text editor even efter certain mouse operations in the graph. #11 198 | * Cut/Copy-and-paste of a node only indirectly declared with and edge specification in the DOT source throws error. #7 199 | * Drag area select does not select anything if the mouse button is released outside the canvas. #6 200 | * Middle-mouse node insertion does not work in Chrome. #5 201 | * De-selecting selected nodes and edges by clicking the canvas does not work in Chrome. #4 202 | * Drawing an edge throws an error in Chrome, but works otherwise. #3 203 | * Inserting a node with shape note, tab, box3d or others throws an error in Chrome, but works otherwise. #2 204 | * Drag-and-drop insert node doesn't work in Chrome. #1 205 | 206 | ## [0.0.2] - 2018-08-21 207 | ### Fixed 208 | * Added a package-lock.json file to fix the dependencies at installation. 209 | 210 | ## [0.0.1] - 2018-08-21 211 | ### Added 212 | * Rendering of a graph from a textual DOT representation. 213 | * Panning and zooming the graph. 214 | * Editing the DOT source in a context sensitive text editor. 215 | * Visual editing of the graph through mouse interactions: 216 | * Insert node shapes by click or drag-and-drop. 217 | * Select default node style, color and fillcolor. 218 | * Draw edges between nodes. 219 | * Select nodes and edges by click or by area drag. 220 | * Delete selected nodes and edges. 221 | * Cut/Copy-and-paste a selected node. 222 | * Automatic update of the DOT source when the graph is visually edited. 223 | * Automatic update of the graph when the DOT source is edited. 224 | * Animated transition of the graph into a new state when changes are made. 225 | * Preservation of the DOT source and the application state during page reloads 226 | by automatic save and retrieve to/from local storage in the browser. 227 | * Options: 228 | * Automatically fit the graph to the available drawing area. 229 | * Select Graphviz layout engine. 230 | * On-line help: 231 | * Keyboard shortcuts 232 | * Mouse interactions 233 | 234 | [Unreleased]: https://github.com/magjac/graphviz-visual-editor/compare/v1.3.0...HEAD 235 | [1.3.0]: https://github.com/magjac/graphviz-visual-editor/compare/v1.2.0...v1.3.0 236 | [1.2.0]: https://github.com/magjac/graphviz-visual-editor/compare/v1.1.0...v1.2.0 237 | [1.1.0]: https://github.com/magjac/graphviz-visual-editor/compare/v1.0.0...v1.1.0 238 | [1.0.0]: https://github.com/magjac/graphviz-visual-editor/compare/v0.6.5...v1.0.0 239 | [0.6.5]: https://github.com/magjac/graphviz-visual-editor/compare/v0.6.4...v0.6.5 240 | [0.6.4]: https://github.com/magjac/graphviz-visual-editor/compare/v0.6.3...v0.6.4 241 | [0.6.3]: https://github.com/magjac/graphviz-visual-editor/compare/v0.6.1...v0.6.3 242 | [0.6.1]: https://github.com/magjac/graphviz-visual-editor/compare/v0.6.0...v0.6.1 243 | [0.6.0]: https://github.com/magjac/graphviz-visual-editor/compare/v0.5.0...v0.6.0 244 | [0.5.0]: https://github.com/magjac/graphviz-visual-editor/compare/v0.4.0...v0.5.0 245 | [0.4.0]: https://github.com/magjac/graphviz-visual-editor/compare/v0.3.1...v0.4.0 246 | [0.3.1]: https://github.com/magjac/graphviz-visual-editor/compare/v0.3.0...v0.3.1 247 | [0.3.0]: https://github.com/magjac/graphviz-visual-editor/compare/v0.2.1...v0.3.0 248 | [0.2.1]: https://github.com/magjac/graphviz-visual-editor/compare/v0.2.0...v0.2.1 249 | [0.2.0]: https://github.com/magjac/graphviz-visual-editor/compare/v0.1.0...v0.2.0 250 | [0.1.0]: https://github.com/magjac/graphviz-visual-editor/compare/v0.0.2...v0.1.0 251 | [0.0.2]: https://github.com/magjac/graphviz-visual-editor/compare/v0.0.1...v0.0.2 252 | [0.0.1]: https://github.com/magjac/graphviz-visual-editor/compare/...v0.0.1 253 | -------------------------------------------------------------------------------- /HOWTO: -------------------------------------------------------------------------------- 1 | # Release 2 | # ======= 3 | # 4 | # Push branch: 5 | # make push 6 | # Create pull request and merge it: 7 | # make pull 8 | # Update "version" in package.json 9 | # Update "version" in package-lock.json by e.g.: 10 | # npm install 11 | # Update CHANGELOG.md 12 | # git commit -m "Updated CHANGELOG with 0.6.4 release" 13 | # Commit with message v, e.g.: 14 | # git add -p 15 | # git commit -m "v0.1.0" 16 | # git tag -a v -m , e.g.: 17 | # git tag -a v0.1.0 -m "Animated growth of entering edges" 18 | # List tags: 19 | # git tag -n 20 | # Push to git and publish on npm 21 | # make public 22 | # Create a release on github 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Magnus Jacobsson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GENERATED_FILES = \ 2 | src/graphvizVersion.js \ 3 | src/graphviz-versions.json \ 4 | src/shapes.js \ 5 | src/versions.json \ 6 | src/dotParser.js \ 7 | graphviz \ 8 | dotfiles.txt \ 9 | 10 | main: $(GENERATED_FILES) 11 | 12 | src/shapes.js: bin/generate-nodes.js 13 | bin/generate-nodes.js > $@.tmp 14 | mv $@.tmp $@ 15 | 16 | src/graphvizVersion.js: bin/generate-graphviz-version.js 17 | bin/generate-graphviz-version.js > $@.tmp 18 | mv $@.tmp $@ 19 | 20 | src/versions.json: CHANGELOG.md bin/generate-versions.py 21 | bin/generate-versions.py CHANGELOG.md > $@.tmp 22 | mv $@.tmp $@ 23 | 24 | src/graphviz-versions.json: graphviz/CHANGELOG.md bin/generate-versions.py 25 | bin/generate-versions.py graphviz/CHANGELOG.md > $@.tmp 26 | mv $@.tmp $@ 27 | 28 | src/dotParser.js: src/dotGrammar.pegjs 29 | npx peggy --format es --output $@.tmp $< 30 | echo "/* eslint-disable */" | cat - $@.tmp > $@.tmp2 31 | mv $@.tmp2 $@ 32 | rm $@.tmp 33 | 34 | graphviz/CHANGELOG.md: graphviz 35 | 36 | graphviz: 37 | git clone --depth 1 https://gitlab.com/graphviz/graphviz.git $@.tmp 38 | mv $@.tmp $@ 39 | 40 | dotfiles.txt: graphviz 41 | find graphviz -name '*.dot' | grep -E -v "(nullderefrebuildlist\.dot|^graphviz/tests/.*)$$" > $@.tmp 42 | mv $@.tmp $@ 43 | 44 | clone-build: 45 | rm -rf /tmp/`basename \`pwd\`` && git clone `pwd`/.git /tmp/`basename \`pwd\`` && cd /tmp/`basename \`pwd\`` && npm install && make && npm run build 46 | 47 | clone-test: 48 | rm -rf /tmp/`basename \`pwd\`` && git clone `pwd`/.git /tmp/`basename \`pwd\`` && cd /tmp/`basename \`pwd\`` && npm install && make && env CI=true npm test 49 | 50 | public: clone-test push-tag 51 | 52 | push-tag: push 53 | git push origin `git rev-parse --abbrev-ref HEAD`:`git tag -l | grep '^v[0-9]*\.[0-9]*\.[0-9]*$$' | tail -1` 54 | 55 | push: 56 | git push origin `git rev-parse --abbrev-ref HEAD` 57 | 58 | pull: 59 | git checkout master 60 | git fetch 61 | git rebase origin/master 62 | 63 | howto: 64 | cat HOWTO 65 | 66 | clean: 67 | rm -rf $(GENERATED_FILES) 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphviz-visual-editor 2 | 3 | Try it at http://magjac.com/graphviz-visual-editor. 4 | 5 | A web application for interactive visual editing of [Graphviz](http://www.graphviz.org) graphs described in the [DOT](https://www.graphviz.org/doc/info/lang.html) language. 6 | 7 | [![CircleCI](https://circleci.com/gh/magjac/graphviz-visual-editor.svg?style=svg)](https://circleci.com/gh/magjac/graphviz-visual-editor) 8 | [![codecov](https://codecov.io/gh/magjac/graphviz-visual-editor/branch/master/graph/badge.svg)](https://codecov.io/gh/magjac/graphviz-visual-editor) 9 | 10 | ## Installation ## 11 | 12 | ``` 13 | git clone https://github.com/magjac/graphviz-visual-editor 14 | cd graphviz-visual-editor 15 | npm install 16 | make 17 | npm run start 18 | ``` 19 | 20 | **NOTE:** The *make* stage emits a few warnings. Ignore them. 21 | 22 | To create an optimized build of the application: 23 | 24 | ``` 25 | npm run build 26 | ``` 27 | 28 | Learn more from the Create React App [README](https://github.com/facebook/create-react-app#npm-run-build-or-yarn-build) and [User Guide](https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md#deployment). 29 | 30 | ## Implemented Features ## 31 | 32 | * Rendering of a graph from a textual [DOT](https://www.graphviz.org/doc/info/lang.html) representation. 33 | * Panning and zooming the graph. 34 | * Editing the DOT source in a context sensitive text editor. 35 | * Visual editing of the graph through mouse interactions: 36 | * Insert node shapes by click or drag-and-drop. 37 | * Select default node style, color and fillcolor. 38 | * Draw edges between nodes. 39 | * Select nodes and edges by click or by area drag and mark them in the text editor. 40 | * Delete selected nodes and edges. 41 | * Cut/Copy-and-paste a selected node. 42 | * Automatic update of the DOT source when the graph is visually edited. 43 | * Automatic update of the graph when the DOT source is edited. 44 | * Animated transition of the graph into a new state when changes are made. 45 | * Preservation of the DOT source and the application state during page reloads by automatic save and retrieve to/from local storage in the browser. 46 | * Export graph as URL and import graph from such an URL. 47 | * Export graph as SVG. 48 | * Options: 49 | * Automatically fit the graph to the available drawing area. 50 | * Select Graphviz layout engine. 51 | * On-line help: 52 | * Keyboard shortcuts 53 | * Mouse interactions 54 | 55 | ## Tested Browsers ## 56 | 57 | * Firefox 71 58 | * Chrome 79 59 | 60 | ## Known Issues ## 61 | 62 | Known issues are **not listed here**. 63 | 64 | All known bugs and enhancement requests are reported as issues on [GitHub](https://github.com/magjac/graphviz-visual-editor) and are listed under the [Issues](https://github.com/magjac/graphviz-visual-editor/issues) tab. 65 | 66 | ### [All open issues](https://github.com/magjac/graphviz-visual-editor/issues) ### 67 | 68 | Lists both bugs and enhancement requests. 69 | 70 | ### [Known open bugs](https://github.com/magjac/graphviz-visual-editor/labels/bug) ### 71 | 72 | ### [Open enhancement requests](https://github.com/magjac/graphviz-visual-editor/labels/enhancement) ### 73 | 74 | ### [Known limitations](https://github.com/magjac/graphviz-visual-editor/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+label%3Abug+label%3Aenhancement) ### 75 | 76 | A limitation is a feature deliberately released without full functionality. A limitation is classifed both as a bug and as an enhancement request to reflect that although it's an enhancement not yet implemented from the author's perspective, it might be perceived as a bug from the user's perspective. 77 | 78 | ### [Closed issues](https://github.com/magjac/graphviz-visual-editor/issues?q=is%3Aissue+is%3Aclosed) ### 79 | 80 | ## Roadmap ## 81 | 82 | There are numerous cool features missing. They are or will be listed as [enhancement requests](https://github.com/magjac/graphviz-visual-editor/labels/enhancement) on [GitHub](https://github.com/magjac/graphviz-visual-editor). 83 | 84 | There is also a [project board](https://github.com/magjac/graphviz-visual-editor/projects/1) showing the short-term activities. 85 | -------------------------------------------------------------------------------- /bin/generate-graphviz-version.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import { Graphviz } from "@hpcc-js/wasm/graphviz"; 4 | 5 | const graphviz = await Graphviz.load(); 6 | const graphvizVersion = graphviz.version(); 7 | 8 | console.log(`const graphvizVersion = "${graphvizVersion}";`); 9 | console.log('export {graphvizVersion};'); 10 | -------------------------------------------------------------------------------- /bin/generate-nodes.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import { Graphviz } from "@hpcc-js/wasm/graphviz"; 4 | 5 | const graphviz = await Graphviz.load(); 6 | 7 | const shapes = [ 8 | "box", 9 | "polygon", 10 | "ellipse", 11 | "oval", 12 | "circle", 13 | "point", 14 | "egg", 15 | "triangle", 16 | "none", 17 | "plaintext", 18 | "plain", 19 | "diamond", 20 | "trapezium", 21 | "parallelogram", 22 | "house", 23 | "pentagon", 24 | "hexagon", 25 | "septagon", 26 | "octagon", 27 | "note", 28 | "tab", 29 | "folder", 30 | "box3d", 31 | "component", 32 | "cylinder", 33 | "rect", 34 | "rectangle", 35 | "square", 36 | "doublecircle", 37 | "doubleoctagon", 38 | "tripleoctagon", 39 | "invtriangle", 40 | "invtrapezium", 41 | "invhouse", 42 | "underline", 43 | "Mdiamond", 44 | "Msquare", 45 | "Mcircle", 46 | /* non-convex polygons */ 47 | /* biological circuit shapes, as specified by SBOLv*/ 48 | /** gene expression symbols **/ 49 | "promoter", 50 | "cds", 51 | "terminator", 52 | "utr", 53 | "insulator", 54 | "ribosite", 55 | "rnastab", 56 | "proteasesite", 57 | "proteinstab", 58 | /** dna construction symbols **/ 59 | "primersite", 60 | "restrictionsite", 61 | "fivepoverhang", 62 | "threepoverhang", 63 | "noverhang", 64 | "assembly", 65 | "signature", 66 | "rpromoter", 67 | "larrow", 68 | "rarrow", 69 | "lpromoter", 70 | /* *** shapes other than polygons *** */ 71 | "record", 72 | "Mrecord", 73 | // "epsf", 74 | "star", 75 | ]; 76 | 77 | console.log('const shapes = {'); 78 | 79 | for (let i = 0; i < shapes.length; i++) { 80 | 81 | const shape = shapes[i]; 82 | 83 | const dotSrc = `digraph "" { 84 | ${shape} [shape=${shape} style=filled label=""] 85 | }`; 86 | 87 | var svg = graphviz.layout(dotSrc, 'svg', 'dot'); 88 | 89 | console.log(`${shape}: \`${svg}\`,`); 90 | } 91 | 92 | const dotSrc = `digraph "" { 93 | "(default)" [style="filled, dashed" fillcolor="white" label=""] 94 | }`; 95 | 96 | var svg = graphviz.layout(dotSrc, 'svg' , 'dot'); 97 | 98 | console.log(`'(default)': \`${svg}\`,`); 99 | 100 | console.log('};'); 101 | console.log(''); 102 | console.log('export {shapes};'); 103 | -------------------------------------------------------------------------------- /bin/generate-versions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """generator of versions JSON file from a CHANGELOG.md file""" 4 | 5 | import json 6 | import re 7 | import sys 8 | from typing import List 9 | 10 | def main(args: List[str]) -> int: # pylint: disable=missing-function-docstring 11 | 12 | with open(args[1], encoding="utf-8") as fp: 13 | versions = {} 14 | for line in fp: 15 | mo = re.match("## \\[([0-9][^\\]]*)] [-–] (.*)$", line) 16 | if mo: 17 | version = mo.group(1) 18 | release_date = mo.group(2) 19 | versions[version] = {"release_date": release_date} 20 | 21 | print(json.dumps(versions, indent=4)) 22 | 23 | return 0 24 | 25 | if __name__ == "__main__": 26 | sys.exit(main(sys.argv)) 27 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 0.05% 6 | removed_code_behavior: adjust_base 7 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | import configCodeCoverage from '@cypress/code-coverage/task.js' 3 | 4 | export default defineConfig({ 5 | projectId: '8wm34o', 6 | defaultCommandTimeout: 10000, 7 | video: true, 8 | e2e: { 9 | setupNodeEvents(on, config) { 10 | configCodeCoverage(on, config); 11 | 12 | return config; 13 | }, 14 | excludeSpecPattern: '*~', 15 | specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /cypress/e2e/export_and_import_as_url.spec.js: -------------------------------------------------------------------------------- 1 | describe('Export as URL', function() { 2 | 3 | it('The DOT source is exported as a URL to the application genereated through the menu alternative Export As URL', function() { 4 | cy.startApplication(); 5 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 6 | 7 | cy.node(1).should('exist'); 8 | cy.node(2).should('exist'); 9 | cy.edge(1).should('exist'); 10 | 11 | cy.node(1).shouldHaveName('Alice'); 12 | cy.node(2).shouldHaveName('Bob'); 13 | cy.edge(1).shouldHaveName('Alice->Bob'); 14 | 15 | cy.nodes().should('have.length', 2); 16 | cy.edges().should('have.length', 1); 17 | 18 | cy.menuButton().click(); 19 | 20 | cy.menuItemExportAsUrl().click(); 21 | 22 | cy.exportGraphAsUrlDialog().should('exist'); 23 | 24 | cy.exportGraphAsUrlExportedUrl().should('have.value', 'http://localhost:3000/?dot=digraph%20%7BAlice%20-%3E%20Bob%7D'); 25 | 26 | cy.exportGraphAsUrlCopyButton().click(); 27 | /* Copy URL does not work inside Cypress because of 28 | * https://github.com/cypress-io/cypress/issues/2851 which wil be 29 | * solved by https://github.com/cypress-io/cypress/issues/311 so 30 | * we don't yet have a way to verify that is works */ 31 | 32 | cy.exportGraphAsUrlCancelButton().click(); 33 | 34 | cy.exportGraphAsUrlDialog().should('not.exist'); 35 | }) 36 | 37 | it('The DOT source is imported from the dot parameter in the URL', function() { 38 | cy.startApplication(); 39 | cy.clearAndRenderDotSource('digraph {}'); 40 | 41 | cy.nodes().should('have.length', 0); 42 | cy.edges().should('have.length', 0); 43 | 44 | cy.visit('http://localhost:3000/?dot=digraph%20%7BAlice%20-%3E%20Bob%7D'); 45 | cy.window().url().should('eq', 'http://localhost:3000/'); 46 | 47 | cy.node(1).should('exist'); 48 | cy.node(2).should('exist'); 49 | cy.edge(1).should('exist'); 50 | 51 | cy.node(1).shouldHaveName('Alice'); 52 | cy.node(2).shouldHaveName('Bob'); 53 | cy.edge(1).shouldHaveName('Alice->Bob'); 54 | 55 | cy.nodes().should('have.length', 2); 56 | cy.edges().should('have.length', 1); 57 | 58 | cy.go('back'); 59 | cy.window().url().should('eq', 'http://localhost:3000/'); 60 | }) 61 | 62 | it('The graph is opened in a new tab when visiting the genereated URL by clicking the open link button in the export graph as URL dialog', function() { 63 | cy.startApplication({ 64 | onBeforeLoad(win) { 65 | cy.stub(win, 'open') 66 | } 67 | }); 68 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 69 | 70 | cy.node(1).should('exist'); 71 | cy.node(2).should('exist'); 72 | cy.edge(1).should('exist'); 73 | 74 | cy.node(1).shouldHaveName('Alice'); 75 | cy.node(2).shouldHaveName('Bob'); 76 | cy.edge(1).shouldHaveName('Alice->Bob'); 77 | 78 | cy.nodes().should('have.length', 2); 79 | cy.edges().should('have.length', 1); 80 | 81 | cy.menuButton().click(); 82 | 83 | cy.menuItemExportAsUrl().click(); 84 | 85 | cy.exportGraphAsUrlDialog().should('exist'); 86 | 87 | cy.exportGraphAsUrlExportedUrl().should('have.value', 'http://localhost:3000/?dot=digraph%20%7BAlice%20-%3E%20Bob%7D'); 88 | 89 | cy.exportGraphAsUrlOpenLinkButton().click(); 90 | 91 | /* We can't test that it actually opens, so we just check that 92 | * window.open() is called with the URL */ 93 | cy.window().its('open').should('be.calledWith', 'http://localhost:3000/?dot=digraph%20%7BAlice%20-%3E%20Bob%7D') 94 | }) 95 | 96 | }) 97 | -------------------------------------------------------------------------------- /cypress/e2e/fullscreen_graph.spec.js: -------------------------------------------------------------------------------- 1 | describe('Show graph only mode', function () { 2 | 3 | const viewportWidth = Cypress.config('viewportWidth'); 4 | 5 | it('Shows the graph only when the open in full button is clicked and shows the full application when it\'s clicked again', function () { 6 | cy.startCleanApplication(); 7 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 8 | 9 | cy.node(1).should('exist'); 10 | cy.node(2).should('exist'); 11 | cy.edge(1).should('exist'); 12 | 13 | cy.node(1).shouldHaveName('Alice'); 14 | cy.node(2).shouldHaveName('Bob'); 15 | cy.edge(1).shouldHaveName('Alice->Bob'); 16 | 17 | cy.nodes().should('have.length', 2); 18 | cy.edges().should('have.length', 1); 19 | 20 | cy.textEditorWrapper().should('be.visible'); 21 | cy.toolbar().should('exist'); 22 | cy.canvas().invoke('width').should('be.lt', viewportWidth / 2) 23 | 24 | cy.fullscreenButton().click(); 25 | 26 | cy.textEditorWrapper().should('not.be.visible'); 27 | cy.toolbar().should('not.exist'); 28 | cy.canvas().invoke('width').should('be.eq', viewportWidth) 29 | 30 | cy.fullscreenButton().click(); 31 | 32 | cy.textEditorWrapper().should('be.visible'); 33 | cy.toolbar().should('exist'); 34 | cy.canvas().invoke('width').should('be.lt', viewportWidth / 2) 35 | }); 36 | 37 | it('Shows the graph only when the \'f\' key is pressed and shows the full application when it\'s pressed again', function () { 38 | cy.startCleanApplication(); 39 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 40 | 41 | cy.node(1).should('exist'); 42 | cy.node(2).should('exist'); 43 | cy.edge(1).should('exist'); 44 | 45 | cy.node(1).shouldHaveName('Alice'); 46 | cy.node(2).shouldHaveName('Bob'); 47 | cy.edge(1).shouldHaveName('Alice->Bob'); 48 | 49 | cy.nodes().should('have.length', 2); 50 | cy.edges().should('have.length', 1); 51 | 52 | cy.textEditorWrapper().should('be.visible'); 53 | cy.toolbar().should('exist'); 54 | cy.canvas().invoke('width').should('be.lt', viewportWidth / 2) 55 | 56 | cy.canvas().click(); 57 | cy.get('body').type('f'); 58 | 59 | cy.textEditorWrapper().should('not.be.visible'); 60 | cy.toolbar().should('not.exist'); 61 | cy.canvas().invoke('width').should('be.eq', viewportWidth) 62 | 63 | cy.get('body').type('f'); 64 | 65 | cy.textEditorWrapper().should('be.visible'); 66 | cy.toolbar().should('exist'); 67 | cy.canvas().invoke('width').should('be.lt', viewportWidth / 2) 68 | }); 69 | 70 | it('Shows the graph only when the open in full button is clicked and shows the full application again when the \'f\' key is pressed', function () { 71 | cy.startCleanApplication(); 72 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 73 | 74 | cy.node(1).should('exist'); 75 | cy.node(2).should('exist'); 76 | cy.edge(1).should('exist'); 77 | 78 | cy.node(1).shouldHaveName('Alice'); 79 | cy.node(2).shouldHaveName('Bob'); 80 | cy.edge(1).shouldHaveName('Alice->Bob'); 81 | 82 | cy.nodes().should('have.length', 2); 83 | cy.edges().should('have.length', 1); 84 | 85 | cy.textEditorWrapper().should('be.visible'); 86 | cy.toolbar().should('exist'); 87 | cy.canvas().invoke('width').should('be.lt', viewportWidth / 2) 88 | 89 | cy.canvas().click(); 90 | cy.fullscreenButton().click(); 91 | 92 | cy.textEditorWrapper().should('not.be.visible'); 93 | cy.toolbar().should('not.exist'); 94 | cy.canvas().invoke('width').should('be.eq', viewportWidth) 95 | 96 | cy.get('body').type('f'); 97 | 98 | cy.textEditorWrapper().should('be.visible'); 99 | cy.toolbar().should('exist'); 100 | cy.canvas().invoke('width').should('be.lt', viewportWidth / 2) 101 | }); 102 | 103 | it('Shows the graph only when the \'f\' key is pressed after a previous fullscreen has been disabled by clicking the open in full button', function () { 104 | cy.startCleanApplication(); 105 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 106 | 107 | cy.node(1).should('exist'); 108 | cy.node(2).should('exist'); 109 | cy.edge(1).should('exist'); 110 | 111 | cy.node(1).shouldHaveName('Alice'); 112 | cy.node(2).shouldHaveName('Bob'); 113 | cy.edge(1).shouldHaveName('Alice->Bob'); 114 | 115 | cy.nodes().should('have.length', 2); 116 | cy.edges().should('have.length', 1); 117 | 118 | cy.textEditorWrapper().should('be.visible'); 119 | cy.toolbar().should('exist'); 120 | cy.canvas().invoke('width').should('be.lt', viewportWidth / 2) 121 | 122 | cy.canvas().click(); 123 | cy.get('body').type('f'); 124 | 125 | cy.textEditorWrapper().should('not.be.visible'); 126 | cy.toolbar().should('not.exist'); 127 | cy.canvas().invoke('width').should('be.eq', viewportWidth) 128 | 129 | cy.fullscreenButton().click(); 130 | 131 | cy.textEditorWrapper().should('be.visible'); 132 | cy.toolbar().should('exist'); 133 | cy.canvas().invoke('width').should('be.lt', viewportWidth / 2) 134 | 135 | cy.get('body').type('f'); 136 | 137 | cy.textEditorWrapper().should('not.be.visible'); 138 | cy.toolbar().should('not.exist'); 139 | cy.canvas().invoke('width').should('be.eq', viewportWidth) 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /cypress/e2e/github.spec.js: -------------------------------------------------------------------------------- 1 | describe('GitHub link', function() { 2 | 3 | it('The GiHub repository is opened in a new tab when the GitHub button is clicked', function() { 4 | cy.startCleanApplication({ 5 | onBeforeLoad(win) { 6 | cy.stub(win, 'open') 7 | } 8 | }); 9 | 10 | /* We can't test that it actually opens, so we just check that 11 | * the link is correct */ 12 | cy.gitHubButton().should('have.prop', 'href', 'https://github.com/magjac/graphviz-visual-editor'); 13 | 14 | }) 15 | 16 | }) 17 | -------------------------------------------------------------------------------- /cypress/e2e/help.spec.js: -------------------------------------------------------------------------------- 1 | describe('Help menu', function() { 2 | 3 | it('A keyboard shortcuts help dialog is shown when keyboard shortcuts is clicked in the help menu', function() { 4 | cy.startCleanApplication(); 5 | cy.helpButton().click(); 6 | cy.helpMenuItemKeyboardShortcuts().click(); 7 | cy.keyboardShortcutsDialog().should('exist'); 8 | cy.keyboardShortcutsTableRows().should('have.length.of.at.least', 10); 9 | cy.keyboardShortcutsDialogCloseButton().click(); 10 | cy.keyboardShortcutsDialog().should('not.exist'); 11 | }) 12 | 13 | it('A mouse operations help dialog is shown when mouse operations is clicked in the help menu', function() { 14 | cy.startCleanApplication(); 15 | cy.helpButton().click(); 16 | cy.helpMenuItemMouseOperations().click(); 17 | cy.mouseOperationsDialog().should('exist'); 18 | cy.mouseOperationsTableRows().should('have.length.of.at.least', 12); 19 | cy.mouseOperationsDialogCloseButton().click(); 20 | cy.mouseOperationsDialog().should('not.exist'); 21 | }) 22 | 23 | it('An about dialog is shown when about is clicked in the help menu', function() { 24 | cy.startCleanApplication(); 25 | cy.helpButton().click(); 26 | cy.helpMenuItemAbout().click(); 27 | cy.aboutDialog().should('exist'); 28 | cy.aboutDialogParagraphs().should('have.length.of.at.least', 4); 29 | cy.aboutDialogCloseButton().click(); 30 | cy.aboutDialog().should('not.exist'); 31 | }) 32 | 33 | it('The help menu is closed when ESC is pressed', function() { 34 | cy.startCleanApplication(); 35 | cy.helpButton().click(); 36 | cy.helpMenu().should('exist'); 37 | cy.helpMenu().type('{esc}'); 38 | cy.helpMenu().should('not.exist'); 39 | }) 40 | 41 | it('The help menu is closed when clicking outside the help menu', function() { 42 | cy.startCleanApplication(); 43 | cy.helpButton().click(); 44 | cy.helpMenu().should('exist'); 45 | cy.helpMenuBackdrop().click(); 46 | cy.helpMenu().should('not.exist'); 47 | }) 48 | 49 | }) 50 | -------------------------------------------------------------------------------- /cypress/e2e/main_menu.spec.js: -------------------------------------------------------------------------------- 1 | describe('Main menu', function() { 2 | 3 | it('The main menu is opened by clicking the menu button', function() { 4 | 5 | const menuItems = [ 6 | 'New', 7 | 'Open from browser', 8 | 'Save as to browser', 9 | 'Rename', 10 | 'Export as URL', 11 | 'Export as SVG', 12 | 'Settings', 13 | ]; 14 | 15 | cy.startCleanApplication(); 16 | 17 | cy.menuButton().click(); 18 | 19 | cy.mainMenu().should('exist'); 20 | cy.mainMenu().should('have.text', menuItems.join('')); 21 | 22 | cy.get('body').type('{esc}', { release: false }); 23 | 24 | cy.mainMenu().should('not.exist'); 25 | 26 | }) 27 | 28 | }) 29 | -------------------------------------------------------------------------------- /cypress/e2e/pan_and_zoom.spec.js: -------------------------------------------------------------------------------- 1 | describe('Pan and zoom of graph', function() { 2 | 3 | it('Zoom in in graph when zoom in button is clicked', function() { 4 | cy.startApplication(); 5 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 6 | 7 | cy.node(1).should('exist'); 8 | cy.node(2).should('exist'); 9 | cy.edge(1).should('exist'); 10 | 11 | cy.node(1).shouldHaveName('Alice'); 12 | cy.node(2).shouldHaveName('Bob'); 13 | cy.edge(1).shouldHaveName('Alice->Bob'); 14 | 15 | cy.nodes().should('have.length', 2); 16 | cy.edges().should('have.length', 1); 17 | 18 | cy.canvasGraph().should('have.attr', 'transform', 'translate(148.875,268.5) scale(1)'); 19 | 20 | cy.zoomInButton().click(); 21 | 22 | cy.canvasGraph().should('have.attr', 'transform', 'translate(143.47500000000002,279.3) scale(1.2)'); 23 | 24 | }) 25 | 26 | it('Zoom out in graph when zoom out button is clicked', function() { 27 | cy.startApplication(); 28 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 29 | 30 | cy.node(1).should('exist'); 31 | cy.node(2).should('exist'); 32 | cy.edge(1).should('exist'); 33 | 34 | cy.node(1).shouldHaveName('Alice'); 35 | cy.node(2).shouldHaveName('Bob'); 36 | cy.edge(1).shouldHaveName('Alice->Bob'); 37 | 38 | cy.nodes().should('have.length', 2); 39 | cy.edges().should('have.length', 1); 40 | 41 | cy.canvasGraph().should('have.attr', 'transform', 'translate(148.875,268.5) scale(1)'); 42 | 43 | cy.zoomOutButton().click(); 44 | 45 | cy.canvasGraph().should('have.attr', 'transform', 'translate(153.375,259.5) scale(0.8333333333333334)'); 46 | 47 | }) 48 | 49 | it('Reset zoom of graph when zoom reset button is clicked', function() { 50 | cy.startApplication(); 51 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 52 | 53 | cy.node(1).should('exist'); 54 | cy.node(2).should('exist'); 55 | cy.edge(1).should('exist'); 56 | 57 | cy.node(1).shouldHaveName('Alice'); 58 | cy.node(2).shouldHaveName('Bob'); 59 | cy.edge(1).shouldHaveName('Alice->Bob'); 60 | 61 | cy.nodes().should('have.length', 2); 62 | cy.edges().should('have.length', 1); 63 | 64 | cy.canvasGraph().should('have.attr', 'transform', 'translate(148.875,268.5) scale(1)'); 65 | 66 | cy.zoomInButton().click(); 67 | 68 | cy.canvasGraph().should('have.attr', 'transform', 'translate(143.47500000000002,279.3) scale(1.2)'); 69 | 70 | cy.zoomResetButton().click(); 71 | 72 | cy.canvasGraph().should('have.attr', 'transform', 'translate(143.92499923706055,268.5) scale(1)'); 73 | 74 | }) 75 | 76 | it('Reset zoom graph to map to available area when zoom out map button is clicked', function() { 77 | cy.startApplication(); 78 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 79 | 80 | cy.node(1).should('exist'); 81 | cy.node(2).should('exist'); 82 | cy.edge(1).should('exist'); 83 | 84 | cy.node(1).shouldHaveName('Alice'); 85 | cy.node(2).shouldHaveName('Bob'); 86 | cy.edge(1).shouldHaveName('Alice->Bob'); 87 | 88 | cy.nodes().should('have.length', 2); 89 | cy.edges().should('have.length', 1); 90 | 91 | cy.canvasGraph().should('have.attr', 'transform', 'translate(148.875,268.5) scale(1)'); 92 | 93 | cy.zoomOutMapButton().click(); 94 | 95 | cy.canvasGraph().should('have.attr', 'transform', 'translate(57.715083385336,414.2068965517241) scale(3.6982758620689653)'); 96 | }) 97 | 98 | }) 99 | -------------------------------------------------------------------------------- /cypress/e2e/rendering.spec.js: -------------------------------------------------------------------------------- 1 | function getAllPropertyNames(obj) { 2 | const proto = Object.getPrototypeOf(obj); 3 | const inherited = (proto) ? getAllPropertyNames(proto) : []; 4 | return [...new Set(Object.getOwnPropertyNames(obj).concat(inherited))]; 5 | } 6 | 7 | describe('Basic rendering from DOT source', function() { 8 | 9 | it('Selects the current DOT source, clears it, enters a simple graph and checks that it renders', function() { 10 | cy.startApplication(); 11 | cy.checkDefaultGraph(); 12 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 13 | 14 | cy.textEditorContent().should('have.text', 'digraph {Alice -> Bob}'); 15 | 16 | cy.canvasGraph().then(graph0 => { 17 | cy.wrap(graph0).findNodes().should('have.length', 2); 18 | cy.wrap(graph0).findNode(1) 19 | .should('exist') 20 | .shouldHaveLabel('Alice'); 21 | cy.wrap(graph0).findNode(2) 22 | .should('exist') 23 | .shouldHaveLabel('Bob'); 24 | cy.wrap(graph0).findEdge(1) 25 | .should('exist') 26 | .shouldHaveName('Alice->Bob'); 27 | }); 28 | }) 29 | 30 | it('Starts by rendering an empty graph stored in browser local storage', function() { 31 | localStorage.setItem('dotSrc', 'digraph {}'); 32 | cy.visit('http://localhost:3000/'); 33 | 34 | cy.textEditorContent().should('have.text', 'digraph {}'); 35 | 36 | cy.canvasGraph().then(graph0 => { 37 | cy.wrap(graph0).findNodes().should('have.length', 0); 38 | }); 39 | }) 40 | 41 | it('Renders DOT source using the engine selected in settings', function() { 42 | cy.startApplicationWithDotSource('digraph {Alice -> Bob}'); 43 | 44 | const engines = [ 45 | 'circo', 46 | 'dot', 47 | 'fdp', 48 | 'neato', 49 | 'osage', 50 | 'patchwork', 51 | 'twopi', 52 | ]; 53 | 54 | engines.forEach(engine => { 55 | cy.settingsButton().click(); 56 | cy.engineSelector().click(); 57 | cy.engineMenuAlternative(engine).click(); 58 | cy.get('body').type('{esc}', { release: false }); 59 | cy.waitForTransition(); 60 | cy.canvasGraph().then(graph0 => { 61 | switch (engine) { 62 | case 'circo': 63 | cy.wrap(graph0).invoke('height').should('be.closeTo', 58.667, 0.0005); 64 | cy.wrap(graph0).invoke('width').should('be.closeTo', 264.227, 0.0005); 65 | break; 66 | case 'dot': 67 | cy.wrap(graph0).invoke('height').should('be.closeTo', 154.667, 0.0005); 68 | cy.wrap(graph0).invoke('width').should('be.closeTo', 95.867, 0.0005); 69 | break; 70 | case 'fdp': 71 | cy.wrap(graph0).invoke('height').should('be.closeTo', 72.907, 0.0005); 72 | cy.wrap(graph0).invoke('width').should('be.closeTo', 184.720, 0.0005); 73 | break; 74 | case 'neato': 75 | cy.wrap(graph0).invoke('height').should('be.closeTo', 72.853, 0.0005); 76 | cy.wrap(graph0).invoke('width').should('be.closeTo', 184.387, 0.0005); 77 | break; 78 | case 'osage': 79 | cy.wrap(graph0).invoke('height').should('be.closeTo', 58.667, 0.0005); 80 | cy.wrap(graph0).invoke('width').should('be.closeTo', 173.693, 0.0005); 81 | break; 82 | case 'patchwork': 83 | cy.wrap(graph0).invoke('height').should('be.closeTo', 70.293, 0.0005); 84 | // Workaround for difference between Chrome 110 headed and Chrome 109 headless: 85 | if (Cypress.browser.isHeadless) { 86 | cy.wrap(graph0).invoke('width').should('be.closeTo', 70.756, 0.0005); 87 | } 88 | else { 89 | cy.wrap(graph0).invoke('width').should('be.closeTo', 70.766, 0.0005); 90 | } 91 | break; 92 | case 'twopi': 93 | cy.wrap(graph0).invoke('height').should('be.closeTo', 58.667, 0.0005); 94 | cy.wrap(graph0).invoke('width').should('be.closeTo', 185.440, 0.0005); 95 | break; 96 | } 97 | }); 98 | }); 99 | 100 | }) 101 | 102 | it('Fits the graph to the available area when enabled in settings', function() { 103 | cy.startApplicationWithDotSource('digraph {Alice -> Bob}'); 104 | 105 | cy.canvasGraph().then(graph0 => { 106 | cy.wrap(graph0).invoke('height').should('be.closeTo', 154.667, 0.0005); 107 | cy.wrap(graph0).invoke('width').should('be.closeTo', 95.867, 0.0005); 108 | }); 109 | 110 | cy.settingsButton().click(); 111 | cy.fitSwitch().click(); 112 | cy.get('body').type('{esc}', { release: false }); 113 | 114 | cy.canvasGraph().then(graph0 => { 115 | cy.wrap(graph0).invoke('height').should('eq', 572); 116 | cy.wrap(graph0).invoke('width').should('be.closeTo', 354.541, 0.0005); 117 | }); 118 | 119 | cy.settingsButton().click(); 120 | cy.fitSwitch().click(); 121 | cy.get('body').type('{esc}', { release: false }); 122 | 123 | cy.canvasGraph().then(graph0 => { 124 | cy.wrap(graph0).invoke('height').should('be.closeTo', 154.667, 0.0005); 125 | cy.wrap(graph0).invoke('width').should('be.closeTo', 95.867, 0.0005); 126 | }); 127 | 128 | }) 129 | 130 | it('Does not resize the graph when the window is resized if fit graph is disabled', function() { 131 | cy.startApplicationWithDotSource('digraph {Alice -> Bob}'); 132 | 133 | cy.canvasSvg().then(svg => { 134 | cy.wrap(svg).invoke('width').should('eq', 469); 135 | cy.wrap(svg).invoke('height').should('eq', 572); 136 | cy.wrap(svg).should('have.attr', 'viewBox', '0 0 351.75 429'); 137 | cy.wrap(svg).should('have.attr', 'width', '469'); 138 | cy.wrap(svg).should('have.attr', 'height', '572'); 139 | }); 140 | 141 | cy.canvasGraph().then(graph0 => { 142 | cy.wrap(graph0).invoke('width').should('be.closeTo', 95.867, 0.0005); 143 | cy.wrap(graph0).invoke('height').should('be.closeTo', 154.667, 0.0005); 144 | }); 145 | 146 | cy.viewport(1000 * 2, 660 * 2); 147 | 148 | cy.canvasSvg().then(svg => { 149 | cy.wrap(svg).invoke('width').should('eq', 469); 150 | cy.wrap(svg).invoke('height').should('eq', 572); 151 | cy.wrap(svg).should('have.attr', 'viewBox', '0 0 351.75 429'); 152 | cy.wrap(svg).should('have.attr', 'width', '976'); 153 | cy.wrap(svg).should('have.attr', 'height', '1232'); 154 | }); 155 | 156 | cy.canvasGraph().then(graph0 => { 157 | cy.wrap(graph0).invoke('width').should('be.closeTo', 95.867, 0.0005); 158 | cy.wrap(graph0).invoke('height').should('be.closeTo', 154.667, 0.0005); 159 | }); 160 | 161 | }) 162 | 163 | it('Resizes the graph when the window is resized if fit graph is enabled', function() { 164 | cy.startApplicationWithDotSource('digraph {Alice -> Bob}'); 165 | 166 | cy.settingsButton().click(); 167 | cy.fitSwitch().click(); 168 | cy.get('body').type('{esc}', { release: false }); 169 | 170 | cy.canvasSvg().then(svg => { 171 | cy.wrap(svg).invoke('width').should('eq', 469); 172 | cy.wrap(svg).invoke('height').should('eq', 572); 173 | cy.wrap(svg).should('have.attr', 'viewBox', '0 0 71.90 116.00'); 174 | cy.wrap(svg).should('have.attr', 'width', '469'); 175 | cy.wrap(svg).should('have.attr', 'height', '572'); 176 | }); 177 | 178 | cy.canvasGraph().then(graph0 => { 179 | cy.wrap(graph0).invoke('width').should('be.closeTo', 354.541, 0.0005); 180 | cy.wrap(graph0).invoke('height').should('eq', 572); 181 | }); 182 | 183 | cy.viewport(1000 * 2, 660 * 2); 184 | 185 | cy.canvasSvg().then(svg => { 186 | cy.wrap(svg).invoke('width').should('eq', 469); 187 | cy.wrap(svg).invoke('height').should('eq', 572); 188 | cy.wrap(svg).should('have.attr', 'viewBox', '0 0 71.90 116.00'); 189 | cy.wrap(svg).should('have.attr', 'width', '976'); 190 | cy.wrap(svg).should('have.attr', 'height', '1232'); 191 | }); 192 | 193 | cy.canvasGraph().then(graph0 => { 194 | cy.wrap(graph0).invoke('width').should('be.closeTo', 763.628, 0.0005); 195 | cy.wrap(graph0).invoke('height').should('eq', 1232); 196 | }); 197 | 198 | }) 199 | 200 | it('Renders nodes with names equal to properties of the JavaScript Object type, and edges between them', function() { 201 | const nodeNames = getAllPropertyNames({}); 202 | const dotSrc = `digraph {\n${nodeNames.join('-> \n')}\n}`; 203 | cy.startApplicationWithDotSource(dotSrc); 204 | 205 | const numNodes = nodeNames.length; 206 | const numEdges = nodeNames.length - 1; 207 | 208 | cy.nodes().should('have.length', numNodes); 209 | cy.edges().should('have.length', numEdges); 210 | 211 | cy.wrap(nodeNames).each((nodeName, i) => { 212 | const nodeIndex = i + 1; 213 | cy.node(nodeIndex).should('exist'); 214 | cy.node(nodeIndex).shouldHaveName(nodeName); 215 | if (i > 0) { 216 | const prevNodeName = nodeNames[i - 1]; 217 | const edgeIndex = nodeIndex - 1; 218 | cy.edge(edgeIndex).should('exist'); 219 | cy.edge(edgeIndex).shouldHaveName(`${prevNodeName}->${nodeName}`); 220 | 221 | } 222 | }); 223 | 224 | }) 225 | 226 | }) 227 | -------------------------------------------------------------------------------- /cypress/e2e/select_delete.spec.js: -------------------------------------------------------------------------------- 1 | describe('Selection and deletion in graph', function() { 2 | 3 | it('Selects a node and deletes it and the edge connected to it', function() { 4 | cy.startApplication(); 5 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 6 | 7 | cy.node(1).should('exist'); 8 | cy.node(2).should('exist'); 9 | cy.edge(1).should('exist'); 10 | 11 | cy.node(1).shouldHaveName('Alice'); 12 | cy.node(2).shouldHaveName('Bob'); 13 | cy.edge(1).shouldHaveName('Alice->Bob'); 14 | 15 | cy.nodes().should('have.length', 2); 16 | cy.edges().should('have.length', 1); 17 | 18 | cy.node(1).click(); 19 | cy.get('body').type('{del}'); 20 | cy.waitForTransition(); 21 | 22 | cy.node(1).should('exist'); 23 | cy.node(2).should('not.exist'); 24 | cy.edge(1).should('not.exist'); 25 | 26 | cy.node(1).shouldHaveName('Bob'); 27 | 28 | cy.nodes().should('have.length', 1); 29 | cy.edges().should('have.length', 0); 30 | }) 31 | 32 | it('Selects an edge and deletes it', function() { 33 | cy.startApplication(); 34 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 35 | 36 | cy.node(1).should('exist'); 37 | cy.node(2).should('exist'); 38 | cy.edge(1).should('exist'); 39 | 40 | cy.node(1).shouldHaveName('Alice'); 41 | cy.node(2).shouldHaveName('Bob'); 42 | cy.edge(1).shouldHaveName('Alice->Bob'); 43 | 44 | cy.nodes().should('have.length', 2); 45 | cy.edges().should('have.length', 1); 46 | 47 | cy.edge(1).click(); 48 | cy.get('body').type('{del}'); 49 | cy.waitForTransition(); 50 | 51 | cy.node(1).should('exist'); 52 | cy.node(2).should('exist'); 53 | cy.edge(1).should('not.exist'); 54 | 55 | cy.node(1).shouldHaveName('Alice'); 56 | cy.node(2).shouldHaveName('Bob'); 57 | 58 | cy.nodes().should('have.length', 2); 59 | cy.edges().should('have.length', 0); 60 | }) 61 | 62 | it('Selects a node, adds another node to the selection and deletes them and the connected edge', function() { 63 | cy.startApplication(); 64 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 65 | 66 | cy.node(1).should('exist'); 67 | cy.node(2).should('exist'); 68 | cy.edge(1).should('exist'); 69 | 70 | cy.node(1).shouldHaveName('Alice'); 71 | cy.node(2).shouldHaveName('Bob'); 72 | cy.edge(1).shouldHaveName('Alice->Bob'); 73 | 74 | cy.nodes().should('have.length', 2); 75 | cy.edges().should('have.length', 1); 76 | 77 | cy.node(1).click(); 78 | cy.get('body').type('{shift}', { release: false }) 79 | .node(2).click(); 80 | cy.get('body').type('{del}'); 81 | cy.waitForTransition(); 82 | 83 | cy.node(1).should('not.exist'); 84 | cy.node(2).should('not.exist'); 85 | cy.edge(1).should('not.exist'); 86 | 87 | cy.nodes().should('have.length', 0); 88 | cy.edges().should('have.length', 0); 89 | }) 90 | 91 | }) 92 | -------------------------------------------------------------------------------- /cypress/e2e/select_deselect.spec.js: -------------------------------------------------------------------------------- 1 | describe('Selection and deselection in graph', function() { 2 | 3 | it('Selects a node when clicked', function() { 4 | cy.startApplication(); 5 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 6 | 7 | cy.node(1).should('exist'); 8 | cy.node(2).should('exist'); 9 | cy.edge(1).should('exist'); 10 | 11 | cy.node(1).shouldHaveName('Alice'); 12 | cy.node(2).shouldHaveName('Bob'); 13 | cy.edge(1).shouldHaveName('Alice->Bob'); 14 | 15 | cy.node(1).shouldNotBeSelected(); 16 | cy.node(2).shouldNotBeSelected(); 17 | cy.edge(1).shouldNotBeSelected(); 18 | 19 | cy.node(1).click(); 20 | 21 | cy.node(1).shouldBeSelected(); 22 | cy.node(2).shouldNotBeSelected(); 23 | cy.edge(1).shouldNotBeSelected(); 24 | }) 25 | 26 | it('Deselects a selected node when the graph is clicked', function() { 27 | cy.startApplication(); 28 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 29 | 30 | cy.node(1).should('exist'); 31 | cy.node(2).should('exist'); 32 | cy.edge(1).should('exist'); 33 | 34 | cy.node(1).shouldHaveName('Alice'); 35 | cy.node(2).shouldHaveName('Bob'); 36 | cy.edge(1).shouldHaveName('Alice->Bob'); 37 | 38 | cy.node(1).shouldNotBeSelected(); 39 | cy.node(2).shouldNotBeSelected(); 40 | cy.edge(1).shouldNotBeSelected(); 41 | 42 | cy.node(1).click(); 43 | 44 | cy.node(1).shouldBeSelected(); 45 | cy.node(2).shouldNotBeSelected(); 46 | cy.edge(1).shouldNotBeSelected(); 47 | 48 | cy.canvasGraph().click('topLeft'); 49 | 50 | cy.node(1).shouldNotBeSelected(); 51 | cy.node(2).shouldNotBeSelected(); 52 | cy.edge(1).shouldNotBeSelected(); 53 | }) 54 | 55 | it('Deselects a selected node when the graph is right-clicked', function() { 56 | cy.startApplication(); 57 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 58 | 59 | cy.node(1).should('exist'); 60 | cy.node(2).should('exist'); 61 | cy.edge(1).should('exist'); 62 | 63 | cy.node(1).shouldHaveName('Alice'); 64 | cy.node(2).shouldHaveName('Bob'); 65 | cy.edge(1).shouldHaveName('Alice->Bob'); 66 | 67 | cy.node(1).shouldNotBeSelected(); 68 | cy.node(2).shouldNotBeSelected(); 69 | cy.edge(1).shouldNotBeSelected(); 70 | 71 | cy.node(1).click(); 72 | 73 | cy.node(1).shouldBeSelected(); 74 | cy.node(2).shouldNotBeSelected(); 75 | cy.edge(1).shouldNotBeSelected(); 76 | 77 | cy.canvasGraph().trigger('contextmenu', 'topLeft'); 78 | 79 | cy.node(1).shouldNotBeSelected(); 80 | cy.node(2).shouldNotBeSelected(); 81 | cy.edge(1).shouldNotBeSelected(); 82 | }) 83 | 84 | it('Deselects a selected node when another node is clicked and selects that node instead', function() { 85 | cy.startApplication(); 86 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 87 | 88 | cy.node(1).should('exist'); 89 | cy.node(2).should('exist'); 90 | cy.edge(1).should('exist'); 91 | 92 | cy.node(1).shouldHaveName('Alice'); 93 | cy.node(2).shouldHaveName('Bob'); 94 | cy.edge(1).shouldHaveName('Alice->Bob'); 95 | 96 | cy.node(1).shouldNotBeSelected(); 97 | cy.node(2).shouldNotBeSelected(); 98 | cy.edge(1).shouldNotBeSelected(); 99 | 100 | cy.node(1).click(); 101 | 102 | cy.node(1).shouldBeSelected(); 103 | cy.node(2).shouldNotBeSelected(); 104 | cy.edge(1).shouldNotBeSelected(); 105 | 106 | cy.node(2).click(); 107 | 108 | cy.node(1).shouldNotBeSelected(); 109 | cy.node(2).shouldBeSelected(); 110 | cy.edge(1).shouldNotBeSelected(); 111 | }) 112 | 113 | it('Deselects a selected node when ESC is pressed', function() { 114 | cy.startApplication(); 115 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 116 | 117 | cy.node(1).should('exist'); 118 | cy.node(2).should('exist'); 119 | cy.edge(1).should('exist'); 120 | 121 | cy.node(1).shouldHaveName('Alice'); 122 | cy.node(2).shouldHaveName('Bob'); 123 | cy.edge(1).shouldHaveName('Alice->Bob'); 124 | 125 | cy.node(1).shouldNotBeSelected(); 126 | cy.node(2).shouldNotBeSelected(); 127 | cy.edge(1).shouldNotBeSelected(); 128 | 129 | cy.node(1).click(); 130 | 131 | cy.node(1).shouldBeSelected(); 132 | cy.node(2).shouldNotBeSelected(); 133 | cy.edge(1).shouldNotBeSelected(); 134 | 135 | cy.get('body').type('{esc}', { release: false }); 136 | 137 | cy.node(1).shouldNotBeSelected(); 138 | cy.node(2).shouldNotBeSelected(); 139 | cy.edge(1).shouldNotBeSelected(); 140 | }) 141 | 142 | it('Extends selection when another node is shift-clicked', function() { 143 | cy.startApplication(); 144 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 145 | 146 | cy.node(1).should('exist'); 147 | cy.node(2).should('exist'); 148 | cy.edge(1).should('exist'); 149 | 150 | cy.node(1).shouldHaveName('Alice'); 151 | cy.node(2).shouldHaveName('Bob'); 152 | cy.edge(1).shouldHaveName('Alice->Bob'); 153 | 154 | cy.node(1).shouldNotBeSelected(); 155 | cy.node(2).shouldNotBeSelected(); 156 | cy.edge(1).shouldNotBeSelected(); 157 | 158 | cy.node(1).click(); 159 | 160 | cy.node(1).shouldBeSelected(); 161 | cy.node(2).shouldNotBeSelected(); 162 | cy.edge(1).shouldNotBeSelected(); 163 | 164 | cy.get('body').type('{shift}', { release: false }) 165 | .node(2).click(); 166 | 167 | cy.node(1).shouldBeSelected(); 168 | cy.node(2).shouldBeSelected(); 169 | cy.edge(1).shouldNotBeSelected(); 170 | }) 171 | 172 | it('Extends selection when another node is ctrl-clicked', function() { 173 | cy.startApplication(); 174 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 175 | 176 | cy.node(1).should('exist'); 177 | cy.node(2).should('exist'); 178 | cy.edge(1).should('exist'); 179 | 180 | cy.node(1).shouldHaveName('Alice'); 181 | cy.node(2).shouldHaveName('Bob'); 182 | cy.edge(1).shouldHaveName('Alice->Bob'); 183 | 184 | cy.node(1).shouldNotBeSelected(); 185 | cy.node(2).shouldNotBeSelected(); 186 | cy.edge(1).shouldNotBeSelected(); 187 | 188 | cy.node(1).click(); 189 | 190 | cy.node(1).shouldBeSelected(); 191 | cy.node(2).shouldNotBeSelected(); 192 | cy.edge(1).shouldNotBeSelected(); 193 | 194 | cy.get('body').type('{ctrl}', { release: false }) 195 | .node(2).click(); 196 | 197 | cy.node(1).shouldBeSelected(); 198 | cy.node(2).shouldBeSelected(); 199 | cy.edge(1).shouldNotBeSelected(); 200 | }) 201 | 202 | it('Keeps selection when the graph is shift-clicked', function() { 203 | cy.startApplication(); 204 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 205 | 206 | cy.node(1).should('exist'); 207 | cy.node(2).should('exist'); 208 | cy.edge(1).should('exist'); 209 | 210 | cy.node(1).shouldHaveName('Alice'); 211 | cy.node(2).shouldHaveName('Bob'); 212 | cy.edge(1).shouldHaveName('Alice->Bob'); 213 | 214 | cy.node(1).shouldNotBeSelected(); 215 | cy.node(2).shouldNotBeSelected(); 216 | cy.edge(1).shouldNotBeSelected(); 217 | 218 | cy.node(1).click(); 219 | 220 | cy.node(1).shouldBeSelected(); 221 | cy.node(2).shouldNotBeSelected(); 222 | cy.edge(1).shouldNotBeSelected(); 223 | 224 | cy.canvasGraph() 225 | .trigger('click', 'topLeft', {which: 1, shiftKey: true}); 226 | 227 | cy.node(1).shouldBeSelected(); 228 | cy.node(2).shouldNotBeSelected(); 229 | cy.edge(1).shouldNotBeSelected(); 230 | 231 | cy.canvasGraph() 232 | .trigger('click', 'topLeft', {which: 1, shiftKey: false}); 233 | 234 | cy.node(1).shouldNotBeSelected(); 235 | cy.node(2).shouldNotBeSelected(); 236 | cy.edge(1).shouldNotBeSelected(); 237 | 238 | }) 239 | 240 | it('Keeps selection when the graph is ctrl-clicked', function() { 241 | cy.startApplication(); 242 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 243 | 244 | cy.node(1).should('exist'); 245 | cy.node(2).should('exist'); 246 | cy.edge(1).should('exist'); 247 | 248 | cy.node(1).shouldHaveName('Alice'); 249 | cy.node(2).shouldHaveName('Bob'); 250 | cy.edge(1).shouldHaveName('Alice->Bob'); 251 | 252 | cy.node(1).shouldNotBeSelected(); 253 | cy.node(2).shouldNotBeSelected(); 254 | cy.edge(1).shouldNotBeSelected(); 255 | 256 | cy.node(1).click(); 257 | 258 | cy.node(1).shouldBeSelected(); 259 | cy.node(2).shouldNotBeSelected(); 260 | cy.edge(1).shouldNotBeSelected(); 261 | 262 | cy.canvasGraph() 263 | .trigger('click', 'topLeft', {which: 1, ctrlKey: true}); 264 | 265 | cy.node(1).shouldBeSelected(); 266 | cy.node(2).shouldNotBeSelected(); 267 | cy.edge(1).shouldNotBeSelected(); 268 | 269 | cy.canvasGraph() 270 | .trigger('click', 'topLeft', {which: 1, ctrlKey: false}); 271 | 272 | cy.node(1).shouldNotBeSelected(); 273 | cy.node(2).shouldNotBeSelected(); 274 | cy.edge(1).shouldNotBeSelected(); 275 | 276 | }) 277 | 278 | it('Selects an edge when clicked', function() { 279 | cy.startApplication(); 280 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 281 | 282 | cy.node(1).should('exist'); 283 | cy.node(2).should('exist'); 284 | cy.edge(1).should('exist'); 285 | 286 | cy.node(1).shouldHaveName('Alice'); 287 | cy.node(2).shouldHaveName('Bob'); 288 | cy.edge(1).shouldHaveName('Alice->Bob'); 289 | 290 | cy.node(1).shouldNotBeSelected(); 291 | cy.node(2).shouldNotBeSelected(); 292 | cy.edge(1).shouldNotBeSelected(); 293 | 294 | cy.edge(1).click(); 295 | 296 | cy.node(1).shouldNotBeSelected(); 297 | cy.node(2).shouldNotBeSelected(); 298 | cy.edge(1).shouldBeSelected(); 299 | }) 300 | 301 | it('Deselects a selected edge when the graph is clicked', function() { 302 | cy.startApplication(); 303 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 304 | 305 | cy.node(1).should('exist'); 306 | cy.node(2).should('exist'); 307 | cy.edge(1).should('exist'); 308 | 309 | cy.node(1).shouldHaveName('Alice'); 310 | cy.node(2).shouldHaveName('Bob'); 311 | cy.edge(1).shouldHaveName('Alice->Bob'); 312 | 313 | cy.node(1).shouldNotBeSelected(); 314 | cy.node(2).shouldNotBeSelected(); 315 | cy.edge(1).shouldNotBeSelected(); 316 | 317 | cy.edge(1).click(); 318 | 319 | cy.node(1).shouldNotBeSelected(); 320 | cy.node(2).shouldNotBeSelected(); 321 | cy.edge(1).shouldBeSelected(); 322 | 323 | cy.canvasGraph().click('topLeft'); 324 | 325 | cy.node(1).shouldNotBeSelected(); 326 | cy.node(2).shouldNotBeSelected(); 327 | cy.edge(1).shouldNotBeSelected(); 328 | }) 329 | 330 | it('Deselects a selected edge when another edge is clicked and selects that edge instead', function() { 331 | cy.startApplication(); 332 | cy.clearAndRenderDotSource('digraph {Alice -> Bob -> Alice}'); 333 | 334 | cy.node(1).should('exist'); 335 | cy.node(2).should('exist'); 336 | cy.edge(1).should('exist'); 337 | cy.edge(2).should('exist'); 338 | 339 | cy.node(1).shouldHaveName('Alice'); 340 | cy.node(2).shouldHaveName('Bob'); 341 | cy.edge(1).shouldHaveName('Alice->Bob'); 342 | cy.edge(2).shouldHaveName('Bob->Alice'); 343 | 344 | cy.node(1).shouldNotBeSelected(); 345 | cy.node(2).shouldNotBeSelected(); 346 | cy.edge(1).shouldNotBeSelected(); 347 | cy.edge(2).shouldNotBeSelected(); 348 | 349 | cy.edge(1).click(); 350 | 351 | cy.node(1).shouldNotBeSelected(); 352 | cy.node(2).shouldNotBeSelected(); 353 | cy.edge(1).shouldBeSelected(); 354 | cy.edge(2).shouldNotBeSelected(); 355 | 356 | cy.edge(2).click(); 357 | 358 | cy.node(1).shouldNotBeSelected(); 359 | cy.node(2).shouldNotBeSelected(); 360 | cy.edge(1).shouldNotBeSelected(); 361 | cy.edge(2).shouldBeSelected(); 362 | }) 363 | 364 | it('Extends selection when another edge is shift-clicked', function() { 365 | cy.startApplication(); 366 | cy.clearAndRenderDotSource('digraph {Alice -> Bob -> Alice}'); 367 | 368 | cy.node(1).should('exist'); 369 | cy.node(2).should('exist'); 370 | cy.edge(1).should('exist'); 371 | cy.edge(2).should('exist'); 372 | 373 | cy.node(1).shouldHaveName('Alice'); 374 | cy.node(2).shouldHaveName('Bob'); 375 | cy.edge(1).shouldHaveName('Alice->Bob'); 376 | cy.edge(2).shouldHaveName('Bob->Alice'); 377 | 378 | cy.node(1).shouldNotBeSelected(); 379 | cy.node(2).shouldNotBeSelected(); 380 | cy.edge(1).shouldNotBeSelected(); 381 | cy.edge(2).shouldNotBeSelected(); 382 | 383 | cy.edge(1).click(); 384 | 385 | cy.node(1).shouldNotBeSelected(); 386 | cy.node(2).shouldNotBeSelected(); 387 | cy.edge(1).shouldBeSelected(); 388 | cy.edge(2).shouldNotBeSelected(); 389 | 390 | cy.get('body').type('{shift}', { release: false }) 391 | .edge(2).click(); 392 | 393 | cy.node(1).shouldNotBeSelected(); 394 | cy.node(2).shouldNotBeSelected(); 395 | cy.edge(1).shouldBeSelected(); 396 | cy.edge(2).shouldBeSelected(); 397 | }) 398 | 399 | it('Extends selection when another edge is ctrl-clicked', function() { 400 | cy.startApplication(); 401 | cy.clearAndRenderDotSource('digraph {Alice -> Bob -> Alice}'); 402 | 403 | cy.node(1).should('exist'); 404 | cy.node(2).should('exist'); 405 | cy.edge(1).should('exist'); 406 | cy.edge(2).should('exist'); 407 | 408 | cy.node(1).shouldHaveName('Alice'); 409 | cy.node(2).shouldHaveName('Bob'); 410 | cy.edge(1).shouldHaveName('Alice->Bob'); 411 | cy.edge(2).shouldHaveName('Bob->Alice'); 412 | 413 | cy.node(1).shouldNotBeSelected(); 414 | cy.node(2).shouldNotBeSelected(); 415 | cy.edge(1).shouldNotBeSelected(); 416 | cy.edge(2).shouldNotBeSelected(); 417 | 418 | cy.edge(1).click(); 419 | 420 | cy.node(1).shouldNotBeSelected(); 421 | cy.node(2).shouldNotBeSelected(); 422 | cy.edge(1).shouldBeSelected(); 423 | cy.edge(2).shouldNotBeSelected(); 424 | 425 | cy.get('body').type('{ctrl}', { release: false }) 426 | .edge(2).click(); 427 | 428 | cy.node(1).shouldNotBeSelected(); 429 | cy.node(2).shouldNotBeSelected(); 430 | cy.edge(1).shouldBeSelected(); 431 | cy.edge(2).shouldBeSelected(); 432 | }) 433 | 434 | it('Selects nodes and edges by dragging the canvas', function() { 435 | cy.startApplication(); 436 | cy.clearAndRenderDotSource('digraph { Eve -> Alice; Eve -> Bob}'); 437 | 438 | cy.node(1).should('exist'); 439 | cy.node(2).should('exist'); 440 | cy.node(3).should('exist'); 441 | cy.edge(1).should('exist'); 442 | cy.edge(2).should('exist'); 443 | 444 | cy.node(1).shouldHaveName('Eve'); 445 | cy.node(2).shouldHaveName('Alice'); 446 | cy.node(3).shouldHaveName('Bob'); 447 | cy.edge(1).shouldHaveName('Eve->Alice'); 448 | cy.edge(2).shouldHaveName('Eve->Bob'); 449 | 450 | cy.node(1).shouldNotBeSelected(); 451 | cy.node(2).shouldNotBeSelected(); 452 | cy.node(3).shouldNotBeSelected(); 453 | cy.edge(1).shouldNotBeSelected(); 454 | cy.edge(2).shouldNotBeSelected(); 455 | 456 | cy.canvasGraph() 457 | .trigger('mousedown', 'bottomLeft', {which: 1}) 458 | .trigger('mousemove', 'top', {which: 1}) 459 | .trigger('click', 'top', {which: 1}) 460 | 461 | cy.node(1).shouldNotBeSelected(); 462 | cy.node(2).shouldBeSelected(); 463 | cy.node(3).shouldNotBeSelected(); 464 | cy.edge(1).shouldBeSelected(); 465 | cy.edge(2).shouldNotBeSelected(); 466 | 467 | }) 468 | 469 | it('Extends nodes and edges selection by shift-dragging the canvas', function() { 470 | cy.startApplication(); 471 | cy.clearAndRenderDotSource('digraph { Eve -> Alice; Eve -> Bob}'); 472 | 473 | cy.node(1).should('exist'); 474 | cy.node(2).should('exist'); 475 | cy.node(3).should('exist'); 476 | cy.edge(1).should('exist'); 477 | cy.edge(2).should('exist'); 478 | 479 | cy.node(1).shouldHaveName('Eve'); 480 | cy.node(2).shouldHaveName('Alice'); 481 | cy.node(3).shouldHaveName('Bob'); 482 | cy.edge(1).shouldHaveName('Eve->Alice'); 483 | cy.edge(2).shouldHaveName('Eve->Bob'); 484 | 485 | cy.node(1).shouldNotBeSelected(); 486 | cy.node(2).shouldNotBeSelected(); 487 | cy.node(3).shouldNotBeSelected(); 488 | cy.edge(1).shouldNotBeSelected(); 489 | cy.edge(2).shouldNotBeSelected(); 490 | 491 | cy.canvasGraph() 492 | .trigger('mousedown', 'bottomLeft', {which: 1}) 493 | .trigger('mousemove', 'top', {which: 1}) 494 | .trigger('click', 'top', {which: 1}); 495 | 496 | cy.node(1).shouldNotBeSelected(); 497 | cy.node(2).shouldBeSelected(); 498 | cy.node(3).shouldNotBeSelected(); 499 | cy.edge(1).shouldBeSelected(); 500 | cy.edge(2).shouldNotBeSelected(); 501 | 502 | cy.canvasGraph() 503 | .trigger('mousedown', 'bottomRight', {which: 1, shiftKey: true}) 504 | .trigger('mousemove', 'top', {which: 1, shiftKey: true}) 505 | .trigger('click', 'top', {which: 1, shiftKey: true}); 506 | 507 | cy.node(1).shouldNotBeSelected(); 508 | cy.node(2).shouldBeSelected(); 509 | cy.node(3).shouldBeSelected(); 510 | cy.edge(1).shouldBeSelected(); 511 | cy.edge(2).shouldBeSelected(); 512 | 513 | }) 514 | 515 | it('Selects all nodes and edges when Ctrl-A is pressed', function() { 516 | cy.startApplication(); 517 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 518 | 519 | cy.node(1).should('exist'); 520 | cy.node(2).should('exist'); 521 | cy.edge(1).should('exist'); 522 | 523 | cy.node(1).shouldHaveName('Alice'); 524 | cy.node(2).shouldHaveName('Bob'); 525 | cy.edge(1).shouldHaveName('Alice->Bob'); 526 | 527 | cy.node(1).shouldNotBeSelected(); 528 | cy.node(2).shouldNotBeSelected(); 529 | cy.edge(1).shouldNotBeSelected(); 530 | 531 | cy.canvasGraph().click(); 532 | cy.get('body').type('{ctrl}a'); 533 | 534 | cy.node(1).shouldBeSelected(); 535 | cy.node(2).shouldBeSelected(); 536 | cy.edge(1).shouldBeSelected(); 537 | }) 538 | 539 | it('Selects all edges when Shift-Ctrl-A is pressed', function() { 540 | cy.startApplication(); 541 | cy.clearAndRenderDotSource('digraph {Alice -> Bob -> Alice}'); 542 | 543 | cy.node(1).should('exist'); 544 | cy.node(2).should('exist'); 545 | cy.edge(1).should('exist'); 546 | cy.edge(2).should('exist'); 547 | 548 | cy.node(1).shouldHaveName('Alice'); 549 | cy.node(2).shouldHaveName('Bob'); 550 | cy.edge(1).shouldHaveName('Alice->Bob'); 551 | cy.edge(2).shouldHaveName('Bob->Alice'); 552 | 553 | cy.node(1).shouldNotBeSelected(); 554 | cy.node(2).shouldNotBeSelected(); 555 | cy.edge(1).shouldNotBeSelected(); 556 | cy.edge(2).shouldNotBeSelected(); 557 | 558 | cy.canvasGraph().click(); 559 | cy.get('body').type('{ctrl}A'); 560 | 561 | cy.node(1).shouldNotBeSelected(); 562 | cy.node(2).shouldNotBeSelected(); 563 | cy.edge(1).shouldBeSelected(); 564 | cy.edge(2).shouldBeSelected(); 565 | }) 566 | 567 | }) 568 | -------------------------------------------------------------------------------- /cypress/e2e/settings.spec.js: -------------------------------------------------------------------------------- 1 | describe('Settings', function() { 2 | 3 | it('The main menu item Settings opens the settings dialog', function() { 4 | cy.startCleanApplication(); 5 | 6 | cy.menuButton().click(); 7 | cy.menuItemSettings().click() 8 | 9 | cy.settingsDialog().should('exist'); 10 | 11 | cy.get('body').type('{esc}', { release: false }); 12 | 13 | cy.settingsDialog().should('not.exist'); 14 | 15 | }) 16 | 17 | }) 18 | -------------------------------------------------------------------------------- /cypress/e2e/text_editor.spec.js: -------------------------------------------------------------------------------- 1 | describe('Text editor', function() { 2 | 3 | it('A graph is rendered when DOT source code is typed in the text editor', function() { 4 | cy.startCleanApplication(); 5 | cy.textEditorContent().type('{leftArrow}{enter}Alice -> Bob'); 6 | 7 | cy.nodes().should('have.length', 2); 8 | cy.edges().should('have.length', 1); 9 | 10 | cy.node(1).should('exist'); 11 | cy.node(2).should('exist'); 12 | cy.edge(1).should('exist'); 13 | 14 | cy.node(1).shouldHaveName('Alice'); 15 | cy.node(2).shouldHaveName('Bob'); 16 | cy.edge(1).shouldHaveName('Alice->Bob'); 17 | 18 | cy.nodes().should('have.length', 2); 19 | cy.edges().should('have.length', 1); 20 | }) 21 | 22 | it('The previous graph is rendered when a DOT source code change is undone with Ctrl-Z', function() { 23 | cy.startCleanApplication(); 24 | cy.textEditorContent().type('{leftArrow}{enter}Alice'); 25 | 26 | cy.nodes().should('have.length', 1); 27 | cy.edges().should('have.length', 0); 28 | 29 | cy.node(1).should('exist'); 30 | 31 | cy.node(1).shouldHaveName('Alice'); 32 | 33 | cy.textEditorContent().type('{ctrl}z'); 34 | cy.waitForTransition(); 35 | 36 | cy.nodes().should('have.length', 0); 37 | cy.edges().should('have.length', 0); 38 | }) 39 | 40 | it('The graph is re-rendered when an undone DOT source code change is redone with Ctrl-Y', function() { 41 | cy.startCleanApplication(); 42 | cy.textEditorContent().type('{leftArrow}{enter}Alice'); 43 | 44 | cy.nodes().should('have.length', 1); 45 | cy.edges().should('have.length', 0); 46 | 47 | cy.node(1).should('exist'); 48 | 49 | cy.node(1).shouldHaveName('Alice'); 50 | 51 | cy.textEditorContent().type('{ctrl}z'); 52 | cy.waitForTransition(); 53 | 54 | cy.nodes().should('have.length', 0); 55 | cy.edges().should('have.length', 0); 56 | 57 | cy.textEditorContent().type('{ctrl}y'); 58 | cy.waitForTransition(); 59 | 60 | cy.nodes().should('have.length', 1); 61 | cy.edges().should('have.length', 0); 62 | 63 | cy.node(1).should('exist'); 64 | 65 | cy.node(1).shouldHaveName('Alice'); 66 | 67 | }) 68 | 69 | it('A DOT source error is indicated with an error marker in the gutter', function() { 70 | cy.startCleanApplication(); 71 | 72 | cy.nodes().should('have.length', 0); 73 | cy.edges().should('have.length', 0); 74 | 75 | cy.textEditorContent().type('{leftArrow}{enter}-'); 76 | 77 | cy.textEditorContent().type('{enter}'); 78 | 79 | cy.textEditorGutterCells().should('have.length', 4); 80 | 81 | cy.textEditorGutterCells().eq(0).should('not.have.class', 'ace_error'); 82 | cy.textEditorGutterCells().eq(1).should('have.class', 'ace_error'); 83 | cy.textEditorGutterCells().eq(2).should('not.have.class', 'ace_error'); 84 | cy.textEditorGutterCells().eq(3).should('not.have.class', 'ace_error'); 85 | 86 | cy.textEditorTooltip().should('not.exist'); 87 | 88 | cy.textEditorGutter().trigger('mousemove', 40 * 0.5, 12 * 1.5); 89 | 90 | cy.textEditorTooltip().should('exist'); 91 | cy.textEditorTooltip().should('have.text', ' Expected "<", "\\"", "edge", "graph", "node", "subgraph", "{", "}", NUMBER, UNICODE_STRING, or WHITESPACE but "-" found.'); 92 | }) 93 | 94 | it('The line with the DOT source error is scrolled into view when the error icon in the text editor is clicked', function() { 95 | cy.startCleanApplication(); 96 | 97 | cy.nodes().should('have.length', 0); 98 | cy.edges().should('have.length', 0); 99 | 100 | cy.textEditorGutterCellWithError().should('not.exist'); 101 | 102 | const num_blank_rows = 48; 103 | const textEditorContent = cy.textEditorContent(); 104 | let text = '{leftArrow}{enter}' 105 | for (let row = 0; row < num_blank_rows; row++) { 106 | text += '{enter}'; 107 | } 108 | text += '-'; 109 | textEditorContent.type(text); 110 | 111 | cy.textEditorGutterCells().should('to.have.length.of.at.least', 41); 112 | cy.textEditorGutterCells().should('to.have.length.of.at.most', 49); 113 | 114 | cy.textEditorVisibleLines().should('to.have.length.of.at.least', 41); 115 | cy.textEditorVisibleLines().should('to.have.length.of.at.most', 49); 116 | 117 | cy.textEditorGutterCellWithError().should('exist'); 118 | 119 | text = ""; 120 | for (let row = 1; row < num_blank_rows; row++) { 121 | text += '{upArrow}'; 122 | } 123 | cy.textEditorContent().type(text); 124 | 125 | cy.textEditorGutterCellWithError().should('not.exist'); 126 | 127 | cy.textEditorErrorButton().click(); 128 | 129 | cy.textEditorGutterCellWithError().should('exist'); 130 | 131 | }) 132 | 133 | it('A DOT source error is indicated with an error marker in the gutter even if the auxillary DOT parser fails to detect them', function() { 134 | localStorage.setItem('test', JSON.stringify( 135 | { 136 | 'disableDotParsing': true, 137 | } 138 | )); 139 | 140 | cy.startCleanApplication(); 141 | 142 | cy.nodes().should('have.length', 0); 143 | cy.edges().should('have.length', 0); 144 | 145 | cy.textEditorContent().type('{leftArrow}{enter}-'); 146 | 147 | cy.textEditorContent().type('{enter}'); 148 | 149 | cy.textEditorGutterCells().should('have.length', 4); 150 | 151 | cy.textEditorGutterCells().eq(0).should('not.have.class', 'ace_error'); 152 | cy.textEditorGutterCells().eq(1).should('have.class', 'ace_error'); 153 | cy.textEditorGutterCells().eq(2).should('not.have.class', 'ace_error'); 154 | cy.textEditorGutterCells().eq(3).should('not.have.class', 'ace_error'); 155 | 156 | cy.textEditorTooltip().should('not.exist'); 157 | 158 | cy.textEditorGutter().trigger('mousemove', 40 * 0.5, 12 * 1.5); 159 | 160 | cy.textEditorTooltip().should('exist'); 161 | cy.textEditorTooltip().should('have.text', " syntax error in line 2 near '-'\n"); 162 | 163 | }) 164 | 165 | it('A graph is not rendered after typing in the text editor until after the hold-off time specified in settings', function() { 166 | 167 | cy.startCleanApplication(); 168 | 169 | cy.settingsButton().click(); 170 | cy.holdOffTimeInput().should('have.value', '0.2'); 171 | cy.get('body').type('{esc}', { release: false }); 172 | 173 | cy.canvasGraph().then(graph0 => { 174 | cy.wrap(graph0).findNode(1).should('not.exist') 175 | let start; 176 | cy.textEditorContent().type('{leftArrow}{enter}Alice').then(() => { 177 | start = Date.now(); 178 | }); 179 | cy.wrap(graph0).findNode(1).then(node => { 180 | const stop = Date.now(); 181 | const actualHoldOffTime = stop - start; 182 | expect(actualHoldOffTime).to.be.at.least(100); 183 | expect(actualHoldOffTime).to.be.lessThan(1200); 184 | }); 185 | }); 186 | 187 | cy.settingsButton().click(); 188 | cy.holdOffTimeInput().should('have.value', '0.2'); 189 | cy.holdOffTimeInput().type('{backspace}{backspace}{backspace}5'); 190 | cy.holdOffTimeInput().should('have.value', '5'); 191 | cy.get('body').type('{esc}', { release: false }); 192 | 193 | cy.clearDotSource(); 194 | cy.insertDotSource('digraph {}'); 195 | 196 | cy.canvasGraph().then(graph0 => { 197 | cy.wrap(graph0).findNode(1).should('not.exist') 198 | let start; 199 | cy.textEditorContent().type('{leftArrow}{enter}Alice').then(() => { 200 | start = Date.now(); 201 | }); 202 | cy.wrap(graph0).findNode(1).then(node => { 203 | const stop = Date.now(); 204 | const actualHoldOffTime = stop - start; 205 | expect(actualHoldOffTime).to.be.at.least(4900); 206 | expect(actualHoldOffTime).to.be.lessThan(6000); 207 | }); 208 | }); 209 | 210 | }) 211 | 212 | it('The text editor uses tab size specified in settings', function() { 213 | 214 | cy.startCleanApplication(); 215 | 216 | cy.settingsButton().click(); 217 | cy.tabSizeInput().should('have.value', '4'); 218 | cy.get('body').type('{esc}', { release: false }); 219 | 220 | cy.textEditorContent().type('{leftArrow}{enter}Alice{enter}Bob'); 221 | 222 | cy.textEditorContent().should('have.text', 'digraph { Alice Bob}'); 223 | 224 | cy.settingsButton().click(); 225 | cy.tabSizeInput().type('{backspace}2'); 226 | cy.tabSizeInput().should('have.value', '2'); 227 | cy.get('body').type('{esc}', { release: false }); 228 | 229 | cy.clearDotSource(); 230 | cy.insertDotSource('digraph {}'); 231 | 232 | cy.textEditorContent().type('{leftArrow}{enter}Alice{enter}Bob'); 233 | 234 | cy.textEditorContent().should('have.text', 'digraph { Alice Bob}'); 235 | 236 | }) 237 | 238 | it('The text editor uses font size specified in settings', function() { 239 | 240 | cy.startApplicationWithDotSource('digraph {Alice -> Bob}'); 241 | 242 | cy.settingsButton().click(); 243 | cy.fontSizeInput().should('have.value', '12'); 244 | cy.get('body').type('{esc}', { release: false }); 245 | 246 | cy.textEditorContent().should('have.text', 'digraph {Alice -> Bob}'); 247 | cy.textEditor().should('have.css', 'font-size', '12px'); 248 | 249 | cy.settingsButton().click(); 250 | cy.fontSizeInput().type('{backspace}8'); 251 | cy.fontSizeInput().should('have.value', '18'); 252 | cy.get('body').type('{esc}', { release: false }); 253 | 254 | cy.textEditorContent().should('have.text', 'digraph {Alice -> Bob}'); 255 | cy.textEditor().should('have.css', 'font-size', '18px'); 256 | 257 | 258 | }) 259 | 260 | it('A graph is rendered when DOT source code is typed slowly in the text editor', function() { 261 | const dotSrc = '{leftArrow}{enter}Alice->Bob'; 262 | const delays = [0, 10, 20, 50, 100, 200, 300, 400, 500]; 263 | 264 | cy.wrap(delays).each((delay) => { 265 | cy.startCleanApplication(); 266 | cy.textEditorContent().type(dotSrc, {delay: delay, timeout: delay * dotSrc.length + 10000}); 267 | 268 | cy.nodes().should('have.length', 2); 269 | cy.edges().should('have.length', 1); 270 | 271 | cy.node(1).should('exist'); 272 | cy.node(2).should('exist'); 273 | cy.edge(1).should('exist'); 274 | 275 | cy.nodeShouldHaveName(1, 'Alice'); 276 | cy.nodeShouldHaveName(2, 'Bob'); 277 | cy.edgeShouldHaveName(1, 'Alice->Bob'); 278 | 279 | cy.nodes().should('have.length', 2); 280 | cy.edges().should('have.length', 1); 281 | }); 282 | }); 283 | }) 284 | -------------------------------------------------------------------------------- /cypress/e2e/transition.spec.js: -------------------------------------------------------------------------------- 1 | describe('Transitioning when DOT source changes', function() { 2 | 3 | const pathDAttrBefore = 'M31.95,-71.7C31.95,-64.41 31.95,-55.73 31.95,-47.54'; 4 | const pathDAttrAfter = 'M64.2,-18C71.94,-18 80.31,-18 88.32,-18'; 5 | 6 | it('Renders a new graph with shape tweening when enabled in settings', function() { 7 | cy.startApplicationWithDotSource('digraph {Alice -> Bob}'); 8 | 9 | cy.canvasGraph().then(graph0 => { 10 | cy.wrap(graph0).findNodes().should('have.length', 2); 11 | cy.wrap(graph0).findNode(1) 12 | .should('exist') 13 | .shouldHaveLabel('Alice') 14 | .shouldHaveShape('ellipse'); 15 | cy.wrap(graph0).findNode(2) 16 | .should('exist') 17 | .shouldHaveLabel('Bob') 18 | .shouldHaveShape('ellipse'); 19 | }); 20 | 21 | cy.clearDotSource(); 22 | cy.insertDotSource('digraph {node [shape=box] Alice -> Bob}'); 23 | 24 | cy.waitForBusy(); 25 | 26 | cy.canvasGraph().then(graph0 => { 27 | cy.wrap(graph0).findNodes().should('have.length', 2); 28 | cy.wrap(graph0).findNode(1) 29 | .should('exist') 30 | .shouldHaveLabel('Alice') 31 | .shouldHaveShape('path'); 32 | cy.wrap(graph0).findNode(2) 33 | .should('exist') 34 | .shouldHaveLabel('Bob') 35 | .shouldHaveShape('path'); 36 | }); 37 | 38 | cy.waitForNotBusy(); 39 | 40 | cy.canvasGraph().then(graph0 => { 41 | cy.wrap(graph0).findNodes().should('have.length', 2); 42 | cy.wrap(graph0).findNode(1) 43 | .should('exist') 44 | .shouldHaveLabel('Alice') 45 | .shouldHaveShape('polygon'); 46 | cy.wrap(graph0).findNode(2) 47 | .should('exist') 48 | .shouldHaveLabel('Bob') 49 | .shouldHaveShape('polygon'); 50 | }); 51 | 52 | }) 53 | 54 | it('Renders a new graph without shape tweening when disabled in settings', function() { 55 | cy.startApplicationWithDotSource('digraph {Alice -> Bob}'); 56 | 57 | cy.settingsButton().click(); 58 | cy.shapeTweenSwitch().click(); 59 | cy.get('body').type('{esc}', { release: false }); 60 | 61 | cy.canvasGraph().then(graph0 => { 62 | cy.wrap(graph0).findNodes().should('have.length', 2); 63 | cy.wrap(graph0).findNode(1) 64 | .should('exist') 65 | .shouldHaveLabel('Alice') 66 | .shouldHaveShape('ellipse'); 67 | cy.wrap(graph0).findNode(2) 68 | .should('exist') 69 | .shouldHaveLabel('Bob') 70 | .shouldHaveShape('ellipse'); 71 | }); 72 | 73 | cy.clearDotSource(); 74 | cy.insertDotSource('digraph {node [shape=box] Alice -> Bob}'); 75 | 76 | cy.waitForBusy(); 77 | 78 | cy.canvasGraph().then(graph0 => { 79 | cy.wrap(graph0).findNodes().should('have.length', 2); 80 | cy.wrap(graph0).findNode(1) 81 | .should('exist') 82 | .shouldHaveLabel('Alice') 83 | .shouldHaveShape('polygon'); 84 | cy.wrap(graph0).findNode(2) 85 | .should('exist') 86 | .shouldHaveLabel('Bob') 87 | .shouldHaveShape('polygon'); 88 | }); 89 | 90 | cy.waitForNotBusy(); 91 | 92 | cy.canvasGraph().then(graph0 => { 93 | cy.wrap(graph0).findNodes().should('have.length', 2); 94 | cy.wrap(graph0).findNode(1) 95 | .should('exist') 96 | .shouldHaveLabel('Alice') 97 | .shouldHaveShape('polygon'); 98 | cy.wrap(graph0).findNode(2) 99 | .should('exist') 100 | .shouldHaveLabel('Bob') 101 | .shouldHaveShape('polygon'); 102 | }); 103 | 104 | }) 105 | 106 | it('Renders a new graph with path tweening when enabled in settings', function() { 107 | cy.startApplicationWithDotSource('digraph {Alice -> Bob}'); 108 | 109 | cy.settingsButton().click(); 110 | cy.shapeTweenSwitch().click(); 111 | cy.get('body').type('{esc}', { release: false }); 112 | 113 | cy.canvasGraph().then(graph0 => { 114 | cy.wrap(graph0).findEdge(1) 115 | .should('exist') 116 | .shouldHaveName('Alice->Bob') 117 | .shouldHaveShape('path') 118 | .find('> path').should('have.attr', 'd', pathDAttrBefore); 119 | }); 120 | 121 | cy.typeDotSource('{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow} rankdir=LR ', {force: true}); 122 | 123 | cy.waitForBusy(); 124 | 125 | cy.canvasGraph().then(graph0 => { 126 | cy.wrap(graph0).findEdge(1) 127 | .should('exist') 128 | .shouldHaveName('Alice->Bob') 129 | .shouldHaveShape('path') 130 | .find('> path') 131 | .should(path => { 132 | expect(path.attr('d').split(',').length).to.equal(102); 133 | }); 134 | }); 135 | 136 | cy.waitForNotBusy(); 137 | 138 | cy.canvasGraph().then(graph0 => { 139 | cy.wrap(graph0).findEdge(1) 140 | .should('exist') 141 | .shouldHaveName('Alice->Bob') 142 | .shouldHaveShape('path') 143 | .find('> path').should('have.attr', 'd', pathDAttrAfter); 144 | }); 145 | 146 | }) 147 | 148 | it('Renders a new graph with absolute path tweening precision specified in settings', function() { 149 | localStorage.setItem('fitGraph', true); 150 | cy.startApplicationWithDotSource('digraph {\n edge [minlen=5]\n Alice -> Bob\n Alice -> Charlie\n}'); 151 | 152 | cy.settingsButton().click(); 153 | cy.tweenPrecisionRadioButtonAbsolute().click(); 154 | cy.tweenPrecisionRadioButtonAbsolute().should('be.checked'); 155 | cy.tweenPrecisionRadioButtonRelative().should('not.be.checked'); 156 | cy.tweenPrecisionInput().should('have.value', '1'); 157 | cy.tweenPrecisionInput().type('{backspace}50{del}'); 158 | cy.tweenPrecisionInput().should('have.value', '50'); 159 | cy.tweenPrecisionInputAdornment().should('have.text', ' points '); 160 | 161 | cy.get('body').type('{esc}', { release: false }); 162 | 163 | cy.canvasGraph().then(graph0 => { 164 | cy.wrap(graph0).findEdge(1) 165 | .should('exist') 166 | .shouldHaveName('Alice->Bob') 167 | .shouldHaveShape('path') 168 | .find('> path') 169 | .then(path => { 170 | expect(path.attr('d').split(',').length).to.equal(11) 171 | }); 172 | }); 173 | 174 | cy.typeDotSource('{leftArrow}{leftArrow}\nCharlie -> Bob', {force: true}); 175 | 176 | cy.waitForBusy(); 177 | 178 | cy.canvasGraph().then(graph0 => { 179 | cy.wrap(graph0).findEdge(1) 180 | .should('exist') 181 | .shouldHaveName('Alice->Bob') 182 | .shouldHaveShape('path') 183 | .find('> path') 184 | .should(path => { 185 | expect(path.attr('d').split(',').length).to.not.equal(11) 186 | }) 187 | .then(path => { 188 | expect(path.attr('d').split(',').length).to.equal(10); 189 | }); 190 | }); 191 | 192 | cy.waitForNotBusy(); 193 | 194 | cy.canvasGraph().then(graph0 => { 195 | cy.wrap(graph0).findEdge(1) 196 | .should('exist') 197 | .shouldHaveName('Alice->Bob') 198 | .shouldHaveShape('path') 199 | .find('> path') 200 | .should(path => { 201 | expect(path.attr('d').split(',').length).to.equal(11) 202 | }) 203 | }); 204 | 205 | }); 206 | 207 | it('Renders a new graph with relative path tweening precision specified in settings', function() { 208 | localStorage.setItem('fitGraph', true); 209 | cy.startApplicationWithDotSource('digraph {\n edge [minlen=5]\n Alice -> Bob\n Alice -> Charlie\n}'); 210 | 211 | cy.settingsButton().click(); 212 | cy.tweenPrecisionRadioButtonAbsolute().should('not.be.checked'); 213 | cy.tweenPrecisionRadioButtonRelative().should('be.checked'); 214 | cy.tweenPrecisionInput().should('have.value', '1'); 215 | cy.tweenPrecisionInput().type('{backspace}50{del}'); 216 | cy.tweenPrecisionInput().should('have.value', '50'); 217 | cy.tweenPrecisionInputAdornment().should('have.text', ' % '); 218 | 219 | cy.get('body').type('{esc}', { release: false }); 220 | 221 | cy.canvasGraph().then(graph0 => { 222 | cy.wrap(graph0).findEdge(1) 223 | .should('exist') 224 | .shouldHaveName('Alice->Bob') 225 | .shouldHaveShape('path') 226 | .find('> path') 227 | .then(path => { 228 | expect(path.attr('d').split(',').length).to.equal(11) 229 | }); 230 | }); 231 | 232 | cy.typeDotSource('{leftArrow}{leftArrow}\nCharlie -> Bob', {force: true}); 233 | 234 | cy.waitForBusy(); 235 | 236 | cy.canvasGraph().then(graph0 => { 237 | cy.wrap(graph0).findEdge(1) 238 | .should('exist') 239 | .shouldHaveName('Alice->Bob') 240 | .shouldHaveShape('path') 241 | .find('> path') 242 | .should(path => { 243 | expect(path.attr('d').split(',').length).to.not.equal(11) 244 | }) 245 | .then(path => { 246 | expect(path.attr('d').split(',').length).to.equal(4); 247 | }); 248 | }); 249 | 250 | cy.waitForNotBusy(); 251 | 252 | cy.canvasGraph().then(graph0 => { 253 | cy.wrap(graph0).findEdge(1) 254 | .should('exist') 255 | .shouldHaveName('Alice->Bob') 256 | .shouldHaveShape('path') 257 | .find('> path') 258 | .should(path => { 259 | expect(path.attr('d').split(',').length).to.equal(11) 260 | }) 261 | }); 262 | 263 | }); 264 | 265 | it('Renders a new graph without path tweening when disabled in settings', function() { 266 | cy.startApplicationWithDotSource('digraph {Alice -> Bob}'); 267 | 268 | cy.settingsButton().click(); 269 | cy.pathTweenSwitch().click(); 270 | cy.shapeTweenSwitch().click(); 271 | cy.get('body').type('{esc}', { release: false }); 272 | 273 | cy.canvasGraph().then(graph0 => { 274 | cy.wrap(graph0).findEdge(1) 275 | .should('exist') 276 | .shouldHaveName('Alice->Bob') 277 | .shouldHaveShape('path') 278 | .find('> path').should('have.attr', 'd', pathDAttrBefore); 279 | }); 280 | 281 | cy.typeDotSource('{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow} rankdir=LR ', {force: true}); 282 | 283 | cy.waitForBusy(); 284 | 285 | cy.canvasGraph().then(graph0 => { 286 | cy.wrap(graph0).findEdge(1) 287 | .should('exist') 288 | .shouldHaveName('Alice->Bob') 289 | .shouldHaveShape('path') 290 | .find('> path').first() 291 | .should('not.have.attr', 'd', pathDAttrBefore) 292 | .then(path => { 293 | expect(path.attr('d').split(',').length).to.equal(5); 294 | }); 295 | }); 296 | 297 | cy.waitForNotBusy(); 298 | 299 | cy.canvasGraph().then(graph0 => { 300 | cy.wrap(graph0).findEdge(1) 301 | .should('exist') 302 | .shouldHaveName('Alice->Bob') 303 | .shouldHaveShape('path') 304 | .find('> path').should('have.attr', 'd', pathDAttrAfter); 305 | }); 306 | 307 | }) 308 | 309 | it('The transition duration is set through the transition duration input field in settings', function() { 310 | cy.startApplicationWithDotSource('digraph {Alice Bob}'); 311 | 312 | cy.settingsButton().click(); 313 | cy.transitionDurationInput().should('have.value', '1'); 314 | cy.get('body').type('{esc}', { release: false }); 315 | 316 | cy.canvasGraph().then(graph0 => { 317 | cy.wrap(graph0).findEdge(1) 318 | .should('not.exist') 319 | }); 320 | 321 | cy.typeDotSource('{leftArrow}{leftArrow}{leftArrow}{leftArrow}-> ', {force: true}); 322 | 323 | let start; 324 | cy.waitForBusy().then(() => { 325 | start = Date.now(); 326 | }); 327 | 328 | cy.waitForNotBusy().then(() => { 329 | const stop = Date.now(); 330 | const actualTransitionDuration = stop - start; 331 | expect(actualTransitionDuration).to.be.at.least(900); 332 | expect(actualTransitionDuration).to.be.lessThan(1900); 333 | }); 334 | 335 | cy.settingsButton().click(); 336 | cy.transitionDurationInput().type('{backspace}5'); 337 | cy.get('body').type('{esc}', { release: false }); 338 | 339 | cy.typeDotSource('{leftArrow}{leftArrow}{leftArrow}{leftArrow}{backspace}{backspace}{backspace}', {force: true}); 340 | 341 | cy.waitForBusy().then(() => { 342 | start = Date.now(); 343 | }); 344 | 345 | cy.waitForNotBusy().then(() => { 346 | const stop = Date.now(); 347 | const actualTransitionDuration = stop - start; 348 | expect(actualTransitionDuration).to.be.at.least(4900); 349 | expect(actualTransitionDuration).to.be.lessThan(5900); 350 | }); 351 | 352 | }) 353 | 354 | }) 355 | -------------------------------------------------------------------------------- /cypress/e2e/undo_redo.spec.js: -------------------------------------------------------------------------------- 1 | describe('Undo and redo of last DOT source change', function() { 2 | 3 | it('Undo insertion of a node by pressing ctrl-Z in the graph', function() { 4 | cy.startApplication(); 5 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 6 | 7 | cy.node(1).should('exist'); 8 | cy.node(2).should('exist'); 9 | cy.edge(1).should('exist'); 10 | 11 | cy.node(1).shouldHaveName('Alice'); 12 | cy.node(2).shouldHaveName('Bob'); 13 | cy.edge(1).shouldHaveName('Alice->Bob'); 14 | 15 | cy.nodes().should('have.length', 2); 16 | cy.edges().should('have.length', 1); 17 | 18 | cy.canvasGraph().trigger('mousedown', 'topLeft', {which: 2}); 19 | cy.canvasGraph().trigger('mouseup', 'topLeft', {which: 2}); 20 | cy.waitForTransition(); 21 | 22 | cy.node(1).should('exist'); 23 | cy.node(2).should('exist'); 24 | cy.node(3).should('exist'); 25 | cy.edge(1).should('exist'); 26 | 27 | cy.node(1).shouldHaveName('Alice'); 28 | cy.node(2).shouldHaveName('Bob'); 29 | cy.node(3).shouldHaveName('n2'); 30 | cy.edge(1).shouldHaveName('Alice->Bob'); 31 | 32 | cy.nodes().should('have.length', 3); 33 | cy.edges().should('have.length', 1); 34 | 35 | cy.get('body').type('{ctrl}z'); 36 | 37 | cy.waitForTransition(); 38 | 39 | cy.node(1).should('exist'); 40 | cy.node(2).should('exist'); 41 | cy.edge(1).should('exist'); 42 | 43 | cy.node(1).shouldHaveName('Alice'); 44 | cy.node(2).shouldHaveName('Bob'); 45 | cy.edge(1).shouldHaveName('Alice->Bob'); 46 | 47 | cy.nodes().should('have.length', 2); 48 | cy.edges().should('have.length', 1); 49 | }) 50 | 51 | it('Redo undone insertion of a node by pressing ctrl-Y in the graph', function() { 52 | cy.startApplication(); 53 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 54 | 55 | cy.node(1).should('exist'); 56 | cy.node(2).should('exist'); 57 | cy.edge(1).should('exist'); 58 | 59 | cy.node(1).shouldHaveName('Alice'); 60 | cy.node(2).shouldHaveName('Bob'); 61 | cy.edge(1).shouldHaveName('Alice->Bob'); 62 | 63 | cy.nodes().should('have.length', 2); 64 | cy.edges().should('have.length', 1); 65 | 66 | cy.canvasGraph().trigger('mousedown', 'topLeft', {which: 2}); 67 | cy.canvasGraph().trigger('mouseup', 'topLeft', {which: 2}); 68 | cy.waitForTransition(); 69 | 70 | cy.node(1).should('exist'); 71 | cy.node(2).should('exist'); 72 | cy.node(3).should('exist'); 73 | cy.edge(1).should('exist'); 74 | 75 | cy.node(1).shouldHaveName('Alice'); 76 | cy.node(2).shouldHaveName('Bob'); 77 | cy.node(3).shouldHaveName('n2'); 78 | cy.edge(1).shouldHaveName('Alice->Bob'); 79 | 80 | cy.nodes().should('have.length', 3); 81 | cy.edges().should('have.length', 1); 82 | 83 | cy.get('body').type('{ctrl}z'); 84 | 85 | cy.waitForTransition(); 86 | 87 | cy.node(1).should('exist'); 88 | cy.node(2).should('exist'); 89 | cy.edge(1).should('exist'); 90 | 91 | cy.node(1).shouldHaveName('Alice'); 92 | cy.node(2).shouldHaveName('Bob'); 93 | cy.edge(1).shouldHaveName('Alice->Bob'); 94 | 95 | cy.nodes().should('have.length', 2); 96 | cy.edges().should('have.length', 1); 97 | 98 | cy.get('body').type('{ctrl}y'); 99 | 100 | cy.waitForTransition(); 101 | 102 | cy.node(1).should('exist'); 103 | cy.node(2).should('exist'); 104 | cy.node(3).should('exist'); 105 | cy.edge(1).should('exist'); 106 | 107 | cy.node(1).shouldHaveName('Alice'); 108 | cy.node(2).shouldHaveName('Bob'); 109 | cy.node(3).shouldHaveName('n2'); 110 | cy.edge(1).shouldHaveName('Alice->Bob'); 111 | 112 | cy.nodes().should('have.length', 3); 113 | cy.edges().should('have.length', 1); 114 | 115 | }) 116 | 117 | it('Undo insertion of a node by clicking the undo icon in then toolbar', function() { 118 | cy.startApplication(); 119 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 120 | 121 | cy.node(1).should('exist'); 122 | cy.node(2).should('exist'); 123 | cy.edge(1).should('exist'); 124 | 125 | cy.node(1).shouldHaveName('Alice'); 126 | cy.node(2).shouldHaveName('Bob'); 127 | cy.edge(1).shouldHaveName('Alice->Bob'); 128 | 129 | cy.nodes().should('have.length', 2); 130 | cy.edges().should('have.length', 1); 131 | 132 | cy.canvasGraph().trigger('mousedown', 'topLeft', {which: 2}); 133 | cy.canvasGraph().trigger('mouseup', 'topLeft', {which: 2}); 134 | cy.waitForTransition(); 135 | 136 | cy.node(1).should('exist'); 137 | cy.node(2).should('exist'); 138 | cy.node(3).should('exist'); 139 | cy.edge(1).should('exist'); 140 | 141 | cy.node(1).shouldHaveName('Alice'); 142 | cy.node(2).shouldHaveName('Bob'); 143 | cy.node(3).shouldHaveName('n2'); 144 | cy.edge(1).shouldHaveName('Alice->Bob'); 145 | 146 | cy.nodes().should('have.length', 3); 147 | cy.edges().should('have.length', 1); 148 | 149 | cy.undoButton().click(); 150 | 151 | cy.waitForTransition(); 152 | 153 | cy.node(1).should('exist'); 154 | cy.node(2).should('exist'); 155 | cy.edge(1).should('exist'); 156 | 157 | cy.node(1).shouldHaveName('Alice'); 158 | cy.node(2).shouldHaveName('Bob'); 159 | cy.edge(1).shouldHaveName('Alice->Bob'); 160 | 161 | cy.nodes().should('have.length', 2); 162 | cy.edges().should('have.length', 1); 163 | }) 164 | 165 | it('Redo undone insertion of a node by ctrl-Y', function() { 166 | cy.startApplication(); 167 | cy.clearAndRenderDotSource('digraph {Alice -> Bob}'); 168 | 169 | cy.node(1).should('exist'); 170 | cy.node(2).should('exist'); 171 | cy.edge(1).should('exist'); 172 | 173 | cy.node(1).shouldHaveName('Alice'); 174 | cy.node(2).shouldHaveName('Bob'); 175 | cy.edge(1).shouldHaveName('Alice->Bob'); 176 | 177 | cy.nodes().should('have.length', 2); 178 | cy.edges().should('have.length', 1); 179 | 180 | cy.canvasGraph().trigger('mousedown', 'topLeft', {which: 2}); 181 | cy.canvasGraph().trigger('mouseup', 'topLeft', {which: 2}); 182 | cy.waitForTransition(); 183 | 184 | cy.node(1).should('exist'); 185 | cy.node(2).should('exist'); 186 | cy.node(3).should('exist'); 187 | cy.edge(1).should('exist'); 188 | 189 | cy.node(1).shouldHaveName('Alice'); 190 | cy.node(2).shouldHaveName('Bob'); 191 | cy.node(3).shouldHaveName('n2'); 192 | cy.edge(1).shouldHaveName('Alice->Bob'); 193 | 194 | cy.nodes().should('have.length', 3); 195 | cy.edges().should('have.length', 1); 196 | 197 | cy.undoButton().click(); 198 | 199 | cy.waitForTransition(); 200 | 201 | cy.node(1).should('exist'); 202 | cy.node(2).should('exist'); 203 | cy.edge(1).should('exist'); 204 | 205 | cy.node(1).shouldHaveName('Alice'); 206 | cy.node(2).shouldHaveName('Bob'); 207 | cy.edge(1).shouldHaveName('Alice->Bob'); 208 | 209 | cy.nodes().should('have.length', 2); 210 | cy.edges().should('have.length', 1); 211 | 212 | cy.redoButton().click(); 213 | 214 | cy.waitForTransition(); 215 | 216 | cy.node(1).should('exist'); 217 | cy.node(2).should('exist'); 218 | cy.node(3).should('exist'); 219 | cy.edge(1).should('exist'); 220 | 221 | cy.node(1).shouldHaveName('Alice'); 222 | cy.node(2).shouldHaveName('Bob'); 223 | cy.node(3).shouldHaveName('n2'); 224 | cy.edge(1).shouldHaveName('Alice->Bob'); 225 | 226 | cy.nodes().should('have.length', 3); 227 | cy.edges().should('have.length', 1); 228 | 229 | }) 230 | 231 | }) 232 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | import './commands' 2 | import '@cypress/code-coverage/support' 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphviz-visual-editor", 3 | "version": "1.3.0", 4 | "description": "A web application for interactive visual editing of Graphviz graphs described in the DOT language", 5 | "keywords": [ 6 | "Graphviz", 7 | "DOT", 8 | "visual-editor", 9 | "javascript", 10 | "graph-drawing", 11 | "graph-visualization", 12 | "graph-view", 13 | "graphviz-dot-language", 14 | "graphviz-viewer", 15 | "wysiwyg", 16 | "wysiwyg-editor", 17 | "interactive-visualization", 18 | "text-editor", 19 | "web-application " 20 | ], 21 | "type": "module", 22 | "license": "MIT", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/magjac/graphviz-visual-editor.git" 26 | }, 27 | "dependencies": { 28 | "@emotion/react": "^11.13.0", 29 | "@emotion/styled": "^11.13.0", 30 | "@mui/codemod": "^5.15.14", 31 | "@mui/icons-material": "^5.16.7", 32 | "@mui/material": "^5.16.7", 33 | "ace-builds": "^1.35.5", 34 | "d3-graphviz": "^5.6.0", 35 | "d3-scale-chromatic": "^3.1.0", 36 | "d3-selection": "^3.0.0", 37 | "d3-transition": "^3.0.1", 38 | "d3-zoom": "^3.0.0", 39 | "moment": "^2.30.1", 40 | "prop-types": "^15.8.1", 41 | "qs": "^6.13.0", 42 | "react": "^18.3.1", 43 | "react-ace": "^12.0.0", 44 | "react-color": "^2.19.3", 45 | "react-dom": "^18.3.1", 46 | "react-perfect-scrollbar": "^1.5.8", 47 | "react-scripts": "^5.0.1", 48 | "tss-react": "^4.9.12", 49 | "typeface-roboto": "^1.1.13" 50 | }, 51 | "scripts": { 52 | "start": "react-scripts --max_old_space_size=4096 start", 53 | "start:coverage": "react-scripts --max_old_space_size=4096 -r @cypress/instrument-cra start", 54 | "build": "react-scripts --max_old_space_size=4096 build", 55 | "test": "react-scripts test --env=jsdom --verbose=false", 56 | "test:coverage": "react-scripts test --env=jsdom --coverage --coverageDirectory=./coverage-jest", 57 | "integration-test": "cypress run --config video=${CI:-false} --browser=chrome --headless --record --key a12725d3-851c-4e67-b432-079b4fb1a875", 58 | "merge-coverage": "mkdir -p coverage && cp -p coverage-jest/coverage-final.json coverage/coverage-final-jest.json && cp -p coverage-cypress/coverage-final.json coverage/coverage-final-cypress.json && nyc report --temp-dir coverage --report-dir coverage --reporter lcov --reporter clover --reporter json --reporter text", 59 | "eject": "react-scripts eject" 60 | }, 61 | "homepage": ".", 62 | "devDependencies": { 63 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 64 | "@cypress/code-coverage": "^3.12.45", 65 | "@cypress/instrument-cra": "^1.4.0", 66 | "@harrison-ifeanyichukwu/xml-serializer": "^1.2.2", 67 | "@testing-library/jest-dom": "^6.4.8", 68 | "@testing-library/react": "^16.0.0", 69 | "codecov": "^3.8.3", 70 | "cypress": "^13.13.3", 71 | "enzyme": "^3.11.0", 72 | "istanbul-lib-coverage": "^3.2.2", 73 | "nyc": "^17.0.0", 74 | "peggy": "^4.0.3", 75 | "wait-on": "^8.0.0" 76 | }, 77 | "browserslist": { 78 | "production": [ 79 | ">0.2%", 80 | "not dead", 81 | "not op_mini all" 82 | ], 83 | "development": [ 84 | "last 1 chrome version", 85 | "last 1 firefox version", 86 | "last 1 safari version" 87 | ] 88 | }, 89 | "jest": { 90 | "coveragePathIgnorePatterns": [ 91 | "src/test-utils", 92 | "src/shapes.js", 93 | "src/dotParser.js" 94 | ] 95 | }, 96 | "nyc": { 97 | "report-dir": "coverage-cypress" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /public/@hpcc-js/wasm/dist/graphviz.umd.js: -------------------------------------------------------------------------------- 1 | ../../../../node_modules/@hpcc-js/wasm/dist/graphviz.umd.js -------------------------------------------------------------------------------- /public/GraphvizLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magjac/graphviz-visual-editor/4823bcd2ee1e48b8b6aa051e5750acf3f50c9619/public/GraphvizLogo.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Graphviz Visual Editor 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/AboutDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import withRoot from './withRoot.js'; 5 | import { Dialog } from '@mui/material'; 6 | import { DialogContent } from '@mui/material'; 7 | import { DialogContentText } from '@mui/material'; 8 | import { DialogTitle } from '@mui/material'; 9 | import graphvizVersions from './graphviz-versions.json'; 10 | import packageJSON from '../package.json'; 11 | import versions from './versions.json'; 12 | import { IconButton } from '@mui/material'; 13 | import { Close as CloseIcon } from '@mui/icons-material'; 14 | 15 | const styles = theme => ({ 16 | title: { 17 | display: 'flex', 18 | justifyContent: 'space-between', 19 | }, 20 | }); 21 | 22 | class AboutDialog extends React.Component { 23 | 24 | handleClose = () => { 25 | this.props.onAboutDialogClose(); 26 | }; 27 | 28 | render() { 29 | const { classes } = this.props; 30 | const version = packageJSON.version; 31 | const changelogHeaderId = (() => { 32 | if (versions[version] == null) { 33 | return version; 34 | } 35 | else { 36 | const releaseDate = versions[version].release_date; 37 | return version.replace(/\./g, '') + "---" + releaseDate; 38 | } 39 | })(); 40 | const graphvizVersion = this.props.graphvizVersion; 41 | const graphvizReleaseDate = graphvizVersions[graphvizVersion].release_date; 42 | const graphvizChangelogHeaderId = graphvizVersion.replace(/\./g, '') + "-" + graphvizReleaseDate; 43 | return ( 44 |
45 | 51 |
52 | About the Graphviz Visual Editor 53 | 58 | 59 | 60 |
61 | 62 | 63 | Version 64 | {' '} 65 | 70 | {packageJSON.version} 71 | 72 | 73 |
74 | 75 | The Graphviz Visual Editor is a web application for 76 | interactive visual editing of 77 | {' '} 78 | 83 | Graphviz 84 | 85 | {' '} 86 | graphs described in the 87 | {' '} 88 | 93 | DOT 94 | 95 | {' '} 96 | language. 97 | It is not a general drawing application. 98 | It can only generate graphs that are possible to describe with DOT. 99 | 100 |
101 | 102 | The Graphviz Visual Editor is an 103 | {' '} 104 | 109 | open source 110 | 111 | {' '} 112 | project and is hosted at 113 | {' '} 114 | 119 | GitHub 120 | 121 | . See the 122 | {' '} 123 | 128 | README 129 | 130 | {' '} 131 | for more information. 132 | 133 |
134 | 135 | Based on Graphviz version 136 | {' '} 137 | 142 | {graphvizVersion} 143 | 144 | 145 |
146 |
147 |
148 | ); 149 | } 150 | } 151 | 152 | AboutDialog.propTypes = { 153 | classes: PropTypes.object.isRequired, 154 | }; 155 | 156 | export default withRoot(withStyles(AboutDialog, styles)); 157 | -------------------------------------------------------------------------------- /src/ButtonAppBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import { AppBar } from '@mui/material'; 5 | import { Toolbar } from '@mui/material'; 6 | import { Typography } from '@mui/material'; 7 | import { Button } from '@mui/material'; 8 | import { IconButton } from '@mui/material'; 9 | import { Icon } from '@mui/material'; 10 | import { Menu as MenuIcon } from '@mui/icons-material'; 11 | import { Add as AddIcon } from '@mui/icons-material'; 12 | import { OpenInBrowser as OpenInBrowserIcon } from '@mui/icons-material'; 13 | import { SaveAlt as SaveAltIcon } from '@mui/icons-material'; 14 | import { Undo as UndoIcon } from '@mui/icons-material'; 15 | import { Redo as RedoIcon } from '@mui/icons-material'; 16 | import { ZoomIn as ZoomInIcon } from '@mui/icons-material'; 17 | import { ZoomOut as ZoomOutIcon } from '@mui/icons-material'; 18 | import { ZoomOutMap as ZoomOutMapIcon } from '@mui/icons-material'; 19 | import { Settings as SettingsIcon } from '@mui/icons-material'; 20 | import { Help as HelpIcon } from '@mui/icons-material'; 21 | import GitHubIcon from './GitHubIcon.js' 22 | 23 | const styles = { 24 | root: { 25 | flexGrow: 1, 26 | }, 27 | toolbar: { 28 | backgroundSize: '16px 16px', 29 | backgroundImage: 'linear-gradient(to right, #4ed1f860 1px, transparent 1px), linear-gradient(to bottom, #4ed1f860 1px, transparent 1px)', 30 | backgroundColor: 'white', 31 | }, 32 | flex: { 33 | flexGrow: 1, 34 | }, 35 | menuButton: { 36 | marginLeft: -12, 37 | marginRight: 20, 38 | }, 39 | gitHubLink: { 40 | color: 'inherit', 41 | '&:visited' : { 42 | color: 'inherit', 43 | }, 44 | }, 45 | imageIcon: { 46 | display: 'block', 47 | height: '100%', 48 | verticalAlign: 'middle', 49 | }, 50 | iconRoot: { 51 | height: '64px', 52 | width: '72px', 53 | verticalAlign: 'middle', 54 | }, 55 | }; 56 | 57 | function ButtonAppBar(props) { 58 | const { classes } = props; 59 | 60 | var handleMenuButtonClick = (event) => { 61 | props.onMenuButtonClick(event.currentTarget); 62 | }; 63 | 64 | var handleNewButtonClick = (event) => { 65 | props.onNewButtonClick(event.currentTarget); 66 | }; 67 | 68 | var handleOpenInBrowserButtonClick = (event) => { 69 | props.onOpenInBrowserButtonClick(event.currentTarget); 70 | }; 71 | 72 | var handleSaveAltButtonClick = (event) => { 73 | props.onSaveAltButtonClick(event.currentTarget); 74 | }; 75 | 76 | var handleUndoButtonClick = (event) => { 77 | props.onUndoButtonClick(event.currentTarget); 78 | }; 79 | 80 | var handleRedoButtonClick = (event) => { 81 | props.onRedoButtonClick(event.currentTarget); 82 | }; 83 | 84 | var handleZoomInButtonClick = (event) => { 85 | props.onZoomInButtonClick && props.onZoomInButtonClick(); 86 | }; 87 | 88 | var handleZoomOutButtonClick = (event) => { 89 | props.onZoomOutButtonClick && props.onZoomOutButtonClick(); 90 | }; 91 | 92 | var handleZoomOutMapButtonClick = (event) => { 93 | props.onZoomOutMapButtonClick && props.onZoomOutMapButtonClick(); 94 | }; 95 | 96 | var handleZoomResetButtonClick = (event) => { 97 | props.onZoomResetButtonClick && props.onZoomResetButtonClick(); 98 | }; 99 | 100 | var handleInsertClick = (event) => { 101 | props.onInsertClick(); 102 | }; 103 | 104 | var handleNodeFormatClick = (event) => { 105 | props.onNodeFormatClick('draw'); 106 | }; 107 | 108 | var handleEdgeFormatClick = (event) => { 109 | props.onEdgeFormatClick('draw'); 110 | }; 111 | 112 | var handleSettingsButtonClick = (event) => { 113 | props.onSettingsButtonClick(event.currentTarget); 114 | }; 115 | 116 | var handleHelpButtonClick = (event) => { 117 | props.onHelpButtonClick(event.currentTarget); 118 | }; 119 | 120 | return ( 121 |
122 | 125 | 126 | 133 | 134 | 135 | 142 | 143 | 144 | 151 | 152 | 153 | 160 | 161 | 162 | 170 | 171 | 172 | 180 | 181 | 182 | 187 | 188 | 189 | 190 | Graphviz Visual Editor 191 | 192 | 199 | 200 | 201 | 208 | 209 | 210 | 217 | 218 | 219 | 226 | 233 | 240 | 247 | 253 | 254 | 255 | 262 | 263 | 266 | 267 | 268 | 274 | 275 | 276 | 277 | 278 |
279 | ); 280 | } 281 | 282 | ButtonAppBar.propTypes = { 283 | classes: PropTypes.object.isRequired, 284 | hasUndo: PropTypes.bool.isRequired, 285 | hasRedo: PropTypes.bool.isRequired, 286 | onMenuButtonClick: PropTypes.func.isRequired, 287 | onNewButtonClick: PropTypes.func.isRequired, 288 | onOpenInBrowserButtonClick: PropTypes.func.isRequired, 289 | onSaveAltButtonClick: PropTypes.func.isRequired, 290 | onUndoButtonClick: PropTypes.func.isRequired, 291 | onRedoButtonClick: PropTypes.func.isRequired, 292 | onZoomInButtonClick: PropTypes.func.isRequired, 293 | onZoomOutButtonClick: PropTypes.func.isRequired, 294 | onZoomOutMapButtonClick: PropTypes.func.isRequired, 295 | onZoomResetButtonClick: PropTypes.func.isRequired, 296 | onInsertClick: PropTypes.func.isRequired, 297 | onNodeFormatClick: PropTypes.func.isRequired, 298 | onEdgeFormatClick: PropTypes.func.isRequired, 299 | onSettingsButtonClick: PropTypes.func.isRequired, 300 | onHelpButtonClick: PropTypes.func.isRequired, 301 | }; 302 | 303 | export default withStyles(ButtonAppBar, styles); 304 | -------------------------------------------------------------------------------- /src/ColorPicker.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import withRoot from './withRoot.js'; 5 | import { FormControl } from '@mui/material'; 6 | import { Input } from '@mui/material'; 7 | 8 | import { ChromePicker } from 'react-color' 9 | 10 | const styles = theme => ({ 11 | color: { 12 | width: '36px', 13 | height: '14px', 14 | borderRadius: '2px', 15 | }, 16 | swatch: { 17 | padding: '5px', 18 | verticalAlign: 'middle', 19 | borderRadius: '1px', 20 | boxShadow: '0 0 0 1px rgba(0,0,0,.1)', 21 | display: 'inline-block', 22 | cursor: 'pointer', 23 | }, 24 | popover: { 25 | position: 'absolute', 26 | width: '100%', 27 | zIndex: '2', 28 | }, 29 | input: { 30 | marginLeft: theme.spacing(2), 31 | verticalAlign: 'middle', 32 | width: 100, 33 | }, 34 | }); 35 | 36 | class ColorPicker extends React.Component { 37 | 38 | handleClick = (event) => { 39 | event.stopPropagation(); 40 | this.props.setOpen(!this.props.open); 41 | }; 42 | 43 | handleInputChange = (event) => { 44 | this.props.onChange(event.target.value); 45 | }; 46 | 47 | handleChange = (color) => { 48 | // Workaround for https://github.com/casesandberg/react-color/issues/655 49 | const a = Math.round(color.rgb.a * 255); 50 | this.props.onChange(color.hex + (a === 255 ? '' : Math.floor(a / 16).toString(16) + (a % 16).toString(16))); 51 | }; 52 | 53 | render() { 54 | const { classes } = this.props; 55 | if (this.props.invert) { 56 | var borderBackground = this.props.color; 57 | var contentBackground = '#fff'; 58 | } else { 59 | borderBackground = '#fff'; 60 | contentBackground = this.props.color; 61 | } 62 | return ( 63 |
64 |
65 |
66 |
67 | 68 | 74 | 75 | {this.props.open ? 76 |
77 | 78 |
79 | : 80 | null 81 | } 82 |
83 | ); 84 | } 85 | } 86 | 87 | ColorPicker.propTypes = { 88 | classes: PropTypes.object.isRequired, 89 | }; 90 | 91 | export default withRoot(withStyles(ColorPicker, styles)); 92 | -------------------------------------------------------------------------------- /src/DoYouWantToDeleteDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import withRoot from './withRoot.js'; 5 | import { Close as CloseIcon } from '@mui/icons-material'; 6 | import { IconButton } from '@mui/material'; 7 | import { Button } from '@mui/material'; 8 | import { Dialog } from '@mui/material'; 9 | import { DialogContent } from '@mui/material'; 10 | import { DialogContentText } from '@mui/material'; 11 | import { DialogTitle } from '@mui/material'; 12 | import { DialogActions } from '@mui/material'; 13 | 14 | const styles = theme => ({ 15 | title: { 16 | display: 'flex', 17 | justifyContent: 'space-between', 18 | }, 19 | content: { 20 | overflowY: 'visible', 21 | }, 22 | }); 23 | 24 | class DoYouWantToDeleteDialog extends React.Component { 25 | 26 | handleClose = () => { 27 | this.props.onClose(); 28 | }; 29 | 30 | handleDelete = (event) => { 31 | const askForConfirmationIfExist = false; 32 | this.props.onDelete(this.props.name, askForConfirmationIfExist); 33 | }; 34 | 35 | render() { 36 | const { classes } = this.props; 37 | return ( 38 |
39 | 46 |
47 | Delete {this.props.name}? 48 | 49 | 50 | 51 |
52 | 53 | 54 | Do you want to delete {this.props.name} from the browser's local storage? 55 | 56 | 57 | 58 | 61 | 64 | 65 |
66 |
67 | ); 68 | } 69 | } 70 | 71 | DoYouWantToDeleteDialog.propTypes = { 72 | classes: PropTypes.object.isRequired, 73 | name: PropTypes.string.isRequired, 74 | onClose: PropTypes.func.isRequired, 75 | onDelete: PropTypes.func.isRequired, 76 | }; 77 | 78 | export default withRoot(withStyles(DoYouWantToDeleteDialog, styles)); 79 | -------------------------------------------------------------------------------- /src/DoYouWantToReplaceItDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import withRoot from './withRoot.js'; 5 | import { Close as CloseIcon } from '@mui/icons-material'; 6 | import { IconButton } from '@mui/material'; 7 | import { Button } from '@mui/material'; 8 | import { Dialog } from '@mui/material'; 9 | import { DialogContent } from '@mui/material'; 10 | import { DialogContentText } from '@mui/material'; 11 | import { DialogTitle } from '@mui/material'; 12 | import { DialogActions } from '@mui/material'; 13 | 14 | const styles = theme => ({ 15 | title: { 16 | display: 'flex', 17 | justifyContent: 'space-between', 18 | }, 19 | content: { 20 | overflowY: 'visible', 21 | }, 22 | }); 23 | 24 | class DoYouWantToReplaceItDialog extends React.Component { 25 | 26 | handleClose = () => { 27 | this.props.onClose(); 28 | }; 29 | 30 | handleReplace = () => { 31 | this.props.onReplace(); 32 | }; 33 | 34 | render() { 35 | const { classes } = this.props; 36 | return ( 37 |
38 | 45 |
46 | Replace "{this.props.name}"? 47 | 48 | 49 | 50 |
51 | 52 | 53 | "{this.props.name}" already exists. Do you want to replace it? 54 | 55 | 56 | 57 | 60 | 63 | 64 |
65 |
66 | ); 67 | } 68 | } 69 | 70 | DoYouWantToReplaceItDialog.propTypes = { 71 | classes: PropTypes.object.isRequired, 72 | name: PropTypes.string.isRequired, 73 | onClose: PropTypes.func.isRequired, 74 | onReplace: PropTypes.func.isRequired, 75 | }; 76 | 77 | export default withRoot(withStyles(DoYouWantToReplaceItDialog, styles)); 78 | -------------------------------------------------------------------------------- /src/DotSrcPreview.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import 'react-perfect-scrollbar/dist/css/styles.css'; 5 | import PerfectScrollbar from 'react-perfect-scrollbar' 6 | 7 | const styles = { 8 | scrollbars: { 9 | width: 200, 10 | height: '6em !important', 11 | }, 12 | pre: { 13 | margin: 0, 14 | } 15 | }; 16 | 17 | class DotSrcPreview extends React.Component { 18 | 19 | render() { 20 | const { classes } = this.props; 21 | return ( 22 | 23 |
24 |           {this.props.dotSrc}
25 |         
26 |
27 | ); 28 | } 29 | } 30 | 31 | DotSrcPreview.propTypes = { 32 | dotSrc: PropTypes.string.isRequired, 33 | numLines: PropTypes.number.isRequired, 34 | classes: PropTypes.object.isRequired, 35 | }; 36 | 37 | export default withStyles(DotSrcPreview, styles); 38 | -------------------------------------------------------------------------------- /src/ExportAsSvgDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import withRoot from './withRoot.js'; 5 | import { Close as CloseIcon } from '@mui/icons-material'; 6 | import { IconButton } from '@mui/material'; 7 | import { Button } from '@mui/material'; 8 | import { Dialog } from '@mui/material'; 9 | import { DialogContent } from '@mui/material'; 10 | import { DialogContentText } from '@mui/material'; 11 | import { DialogTitle } from '@mui/material'; 12 | import { DialogActions } from '@mui/material'; 13 | import { Input } from '@mui/material'; 14 | 15 | const styles = theme => ({ 16 | title: { 17 | display: 'flex', 18 | justifyContent: 'space-between', 19 | }, 20 | content: { 21 | overflowY: 'visible', 22 | }, 23 | }); 24 | 25 | class ExportAsSvgDialog extends React.Component { 26 | 27 | constructor(props) { 28 | super(props); 29 | this.state = { 30 | filename: props.defaultFilename, 31 | }; 32 | } 33 | 34 | handleClose = () => { 35 | this.props.onClose(); 36 | }; 37 | 38 | handleExportSvg = () => { 39 | // TODO: move downloadFile function to a utility module 40 | function downloadFile(fileData, fileName, mimeType) { 41 | const fileBlob = new Blob([fileData], {type: mimeType}) 42 | const fileObjectURL = window.URL.createObjectURL(fileBlob) 43 | const tempLink = document.createElement('a') 44 | tempLink.href = fileObjectURL 45 | tempLink.download = fileName 46 | document.body.appendChild(tempLink) 47 | tempLink.click() 48 | document.body.removeChild(tempLink) 49 | } 50 | const fileData = this.props.getSvgString() 51 | const fileName = this.state.filename 52 | const mimeType = 'image/svg+xml' 53 | downloadFile(fileData, fileName, mimeType) 54 | this.props.onClose() 55 | } 56 | 57 | handleChange = (event) => { 58 | this.setState({ 59 | filename: event.target.value 60 | }) 61 | }; 62 | 63 | render() { 64 | const { classes } = this.props; 65 | return ( 66 |
67 | 74 |
75 | 76 | Export Graph as SVG 77 | 78 | 79 | 80 | 81 |
82 | 83 | 84 | Choose a name for the exported SVG file 85 | 86 |
87 | 97 |
98 | 99 | 102 | 105 | 106 |
107 |
108 | ); 109 | } 110 | } 111 | 112 | ExportAsSvgDialog.propTypes = { 113 | classes: PropTypes.object.isRequired, 114 | defaultFilename: PropTypes.string.isRequired, 115 | getSvgString: PropTypes.func.isRequired, 116 | onClose: PropTypes.func.isRequired, 117 | }; 118 | 119 | export default withRoot(withStyles(ExportAsSvgDialog, styles)); 120 | -------------------------------------------------------------------------------- /src/ExportAsUrlDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import withRoot from './withRoot.js'; 5 | import { Close as CloseIcon } from '@mui/icons-material'; 6 | import { Link as LinkIcon } from '@mui/icons-material'; 7 | import { IconButton } from '@mui/material'; 8 | import { Button } from '@mui/material'; 9 | import { Dialog } from '@mui/material'; 10 | import { DialogContent } from '@mui/material'; 11 | import { DialogContentText } from '@mui/material'; 12 | import { DialogTitle } from '@mui/material'; 13 | import { DialogActions } from '@mui/material'; 14 | import { Input } from '@mui/material'; 15 | 16 | const styles = theme => ({ 17 | title: { 18 | display: 'flex', 19 | justifyContent: 'space-between', 20 | }, 21 | content: { 22 | overflowY: 'visible', 23 | }, 24 | }); 25 | 26 | class ExportAsUrlDialog extends React.Component { 27 | 28 | constructor(props) { 29 | super(props); 30 | this.state = { 31 | doYouWantToReplaceItDialogIsOpen: false, 32 | }; 33 | this.name = this.props.defaultNewName; 34 | } 35 | 36 | handleClose = () => { 37 | this.props.onClose(); 38 | }; 39 | 40 | handleCopy = () => { 41 | this.input.select(); 42 | document.execCommand('copy'); 43 | }; 44 | 45 | handleOpen = () => { 46 | window.open(this.props.URL); 47 | }; 48 | 49 | render() { 50 | const { classes } = this.props; 51 | return ( 52 |
53 | 60 |
61 | 62 | Export graph as URL 63 | 64 | 65 | 66 | 67 |
68 | 69 | 70 | The URL below is a link to the application with the DOT source code as an URL parameter. It can be used to share graphs with others. 71 | 72 |
73 | {this.input = input}} 75 | inputProps={{ 76 | size: 60, 77 | }} 78 | autoFocus 79 | id="export" 80 | type="text" 81 | value={this.props.URL} 82 | readOnly 83 | /> 84 | 95 |
96 | 97 | 100 | 103 | 104 |
105 |
106 | ); 107 | } 108 | } 109 | 110 | ExportAsUrlDialog.propTypes = { 111 | classes: PropTypes.object.isRequired, 112 | URL: PropTypes.string.isRequired, 113 | onClose: PropTypes.func.isRequired, 114 | }; 115 | 116 | export default withRoot(withStyles(ExportAsUrlDialog, styles)); 117 | -------------------------------------------------------------------------------- /src/FormatDrawer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useState } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { withStyles } from 'tss-react/mui'; 5 | import { useTheme } from '@mui/material'; 6 | import { Drawer } from '@mui/material'; 7 | import { DialogTitle } from '@mui/material'; 8 | import { Divider } from '@mui/material'; 9 | import { IconButton } from '@mui/material'; 10 | import { ChevronLeft as ChevronLeftIcon } from '@mui/icons-material'; 11 | import { ChevronRight as ChevronRightIcon } from '@mui/icons-material'; 12 | import { FormControl } from '@mui/material'; 13 | import { FormGroup } from '@mui/material'; 14 | import { FormControlLabel } from '@mui/material'; 15 | import { Checkbox } from '@mui/material'; 16 | import { Switch } from '@mui/material'; 17 | import ColorPicker from './ColorPicker.js' 18 | 19 | const drawerWidth = '100%'; 20 | 21 | const styles = theme => ({ 22 | root: { 23 | flexGrow: 1, 24 | }, 25 | hide: { 26 | display: 'none', 27 | }, 28 | drawerPaper: { 29 | position: 'relative', 30 | width: drawerWidth, 31 | height: 'calc(100vh - 64px - 2 * 12px)', 32 | textAlign: 'left', 33 | }, 34 | drawerHeader: { 35 | display: 'flex', 36 | alignItems: 'center', 37 | justifyContent: 'flex-end', 38 | padding: '0 8px', 39 | textTransform: 'capitalize', 40 | ...theme.mixins.toolbar, 41 | }, 42 | styleFormControl: { 43 | }, 44 | styleSwitch: { 45 | marginLeft: theme.spacing(2), 46 | }, 47 | styleCheckbox: { 48 | marginLeft: theme.spacing(0), 49 | marginTop: theme.spacing(-2), 50 | }, 51 | colorFormControl: { 52 | marginLeft: theme.spacing(2), 53 | marginBottom: theme.spacing(1), 54 | }, 55 | colorSwitch: { 56 | marginLeft: theme.spacing(0), 57 | }, 58 | }); 59 | 60 | const emptyStyle = ''; 61 | 62 | const nodeStyles = [ 63 | "dashed", 64 | "dotted", 65 | "solid", 66 | "invis", 67 | "bold", 68 | "filled", 69 | "striped", 70 | "wedged", 71 | "diagonals", 72 | "rounded", 73 | "radial", 74 | ]; 75 | 76 | const edgeStyles = [ 77 | "dashed", 78 | "dotted", 79 | "solid", 80 | "invis", 81 | "bold", 82 | "tapered", 83 | ]; 84 | 85 | const emptyColor = ''; 86 | 87 | const FormatDrawer = ({classes, type, defaultAttributes, onClick, onFormatDrawerClose, onStyleChange, onColorChange, onFillColorChange} ) => { 88 | 89 | const [colorColorPickerIsOpen, setColorColorPickerIsOpen] = useState(false); 90 | const [fillColorColorPickerIsOpen, setFillColorColorPickerIsOpen] = useState(false) 91 | 92 | function getStyleSet() { 93 | if (defaultAttributes.style == null) { 94 | return new Set([]); 95 | } else { 96 | let styleSet = new Set(defaultAttributes.style.split(', ')) 97 | styleSet.add(emptyStyle); 98 | return styleSet; 99 | } 100 | } 101 | 102 | function setStyle(styleSet) { 103 | if (styleSet.size === 0) { 104 | onStyleChange(null); 105 | } else { 106 | styleSet.delete(emptyStyle); 107 | onStyleChange([...styleSet].join(', ')); 108 | } 109 | } 110 | 111 | const handleClick = () => { 112 | setColorColorPickerIsOpen(false); 113 | setFillColorColorPickerIsOpen(false); 114 | onClick(); 115 | }; 116 | 117 | const handleDrawerClose = () => { 118 | onFormatDrawerClose(); 119 | }; 120 | 121 | const handleStyleSwitchChange = (event) => { 122 | let styleSet = getStyleSet(); 123 | styleSet.clear(); 124 | if (event.target.checked) { 125 | styleSet.add(emptyStyle); 126 | } 127 | setStyle(styleSet); 128 | } 129 | 130 | const handleStyleChange = (styleName) => (event) => { 131 | const checked = event.target.checked; 132 | let styleSet = getStyleSet(); 133 | if (checked) { 134 | styleSet.delete(emptyStyle); 135 | styleSet.add(styleName); 136 | } 137 | else { 138 | styleSet.delete(styleName); 139 | } 140 | setStyle(styleSet); 141 | }; 142 | 143 | const handleColorSwitchChange = (event) => { 144 | if (event.target.checked) { 145 | onColorChange(emptyColor); 146 | } else { 147 | onColorChange(null); 148 | } 149 | } 150 | 151 | const handleColorChange = (color) => { 152 | onColorChange(color); 153 | }; 154 | 155 | const handleFillColorSwitchChange = (event) => { 156 | if (event.target.checked) { 157 | onFillColorChange(emptyColor); 158 | } else { 159 | onFillColorChange(null); 160 | } 161 | } 162 | const handleFillColorChange = (color) => { 163 | onFillColorChange(color); 164 | }; 165 | 166 | let styles = type === 'node' ? nodeStyles : edgeStyles; 167 | let currentStyle = getStyleSet(); 168 | const theme = useTheme(); 169 | return ( 170 |
171 | 181 |
182 | 183 | Default {type} attributes 184 | 185 | 186 | {theme.direction === 'rtl' ? : } 187 | 188 |
189 | 190 | 191 | 192 | 200 | } 201 | label="style" 202 | labelPlacement="start" 203 | /> 204 | 205 | 206 | {styles.map((style) => 207 | 216 | } 217 | key={style} 218 | label={style} 219 | /> 220 | )} 221 | 222 | 223 | 227 | 228 | 236 | } 237 | label="color" 238 | labelPlacement="start" 239 | /> 240 | 241 | 242 | handleColorChange(color)} 249 | /> 250 | 251 | 252 | 256 | 257 | 265 | } 266 | label="fillcolor" 267 | labelPlacement="start" 268 | /> 269 | 270 | 271 | handleFillColorChange(color)} 277 | /> 278 | 279 | 280 |
281 |
282 | ); 283 | } 284 | 285 | FormatDrawer.propTypes = { 286 | classes: PropTypes.object.isRequired, 287 | }; 288 | 289 | export default withStyles(FormatDrawer, styles); 290 | -------------------------------------------------------------------------------- /src/GitHubIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SvgIcon } from '@mui/material'; 3 | 4 | function GitHubIcon(props) { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | 12 | export default GitHubIcon; 13 | -------------------------------------------------------------------------------- /src/HelpMenu.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Menu } from '@mui/material'; 3 | import { MenuItem } from '@mui/material'; 4 | 5 | class HelpMenu extends React.Component { 6 | 7 | handleClose = () => { 8 | this.props.onMenuClose(); 9 | }; 10 | 11 | handleKeyboardShortcutsClick = () => { 12 | this.props.onMenuClose(); 13 | this.props.onKeyboardShortcutsClick(); 14 | }; 15 | 16 | handleMouseOperationsClick = () => { 17 | this.props.onMenuClose(); 18 | this.props.onMouseOperationsClick(); 19 | }; 20 | 21 | handleAboutClick = () => { 22 | this.props.onMenuClose(); 23 | this.props.onAboutClick(); 24 | }; 25 | 26 | render() { 27 | 28 | return ( 29 |
30 | 36 | Keyboard shortcuts 37 | Mouse operations 38 | About 39 | 40 |
41 | ); 42 | } 43 | } 44 | 45 | export default HelpMenu; 46 | -------------------------------------------------------------------------------- /src/InsertPanels.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import { Accordion } from '@mui/material'; 5 | import { AccordionDetails } from '@mui/material'; 6 | import { AccordionSummary } from '@mui/material'; 7 | import { Typography } from '@mui/material'; 8 | import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material'; 9 | import {shapes} from './shapes.js'; 10 | 11 | const nodeShapeCategories = [ 12 | { 13 | name: 'Basic shapes', 14 | shapes: [ 15 | "ellipse", 16 | "circle", 17 | "egg", 18 | "triangle", 19 | "box", 20 | "square", 21 | "plaintext", 22 | "plain", 23 | "diamond", 24 | "trapezium", 25 | "parallelogram", 26 | "house", 27 | "pentagon", 28 | "hexagon", 29 | "septagon", 30 | "octagon", 31 | ], 32 | }, 33 | { 34 | name: 'Basic symbols', 35 | shapes: [ 36 | "note", 37 | "tab", 38 | "folder", 39 | "box3d", 40 | "component", 41 | "underline", 42 | "cylinder", 43 | ], 44 | }, 45 | { 46 | name: 'Special shapes', 47 | shapes: [ 48 | "doublecircle", 49 | "invtriangle", 50 | "invtrapezium", 51 | "invhouse", 52 | "doubleoctagon", 53 | "tripleoctagon", 54 | "Mdiamond", 55 | "Msquare", 56 | "Mcircle", 57 | "star", 58 | ], 59 | }, 60 | { 61 | name: 'Gene expression symbols', 62 | shapes: [ 63 | "promoter", 64 | "cds", 65 | "terminator", 66 | "utr", 67 | "insulator", 68 | "ribosite", 69 | "rnastab", 70 | "proteasesite", 71 | "proteinstab", 72 | ], 73 | }, 74 | { 75 | name: 'DNA construction symbols', 76 | shapes: [ 77 | "primersite", 78 | "restrictionsite", 79 | "fivepoverhang", 80 | "threepoverhang", 81 | "noverhang", 82 | "assembly", 83 | "signature", 84 | "rpromoter", 85 | "larrow", 86 | "rarrow", 87 | "lpromoter", 88 | ], 89 | }, 90 | { 91 | name: 'Other shapes', 92 | shapes: [ 93 | "polygon", 94 | "oval", 95 | "point", 96 | "none", 97 | "rect", 98 | "rectangle", 99 | "record", 100 | "Mrecord", 101 | "(default)", 102 | ], 103 | }, 104 | ]; 105 | 106 | const styles = theme => ({ 107 | root: { 108 | width: '100%', 109 | overflowY: 'auto', 110 | height: 'calc(100vh - 64px - 2 * 12px)', 111 | }, 112 | heading: { 113 | fontSize: theme.typography.pxToRem(15), 114 | flexShrink: 0, 115 | }, 116 | columns: { 117 | display: 'flex', 118 | flexWrap: 'wrap', 119 | alignItems: 'flex-start', 120 | }, 121 | column: { 122 | flexBasis: '25%', 123 | flexGrow: '1', 124 | flexShrink: '0', 125 | textAlign: 'start', 126 | }, 127 | }); 128 | 129 | class InsertPanels extends React.Component { 130 | state = { 131 | expanded: null, 132 | }; 133 | 134 | handleClick = () => { 135 | this.props.onClick(); 136 | }; 137 | 138 | handleChange = panel => (event, expanded) => { 139 | this.setState({ 140 | expanded: expanded ? panel : false, 141 | }); 142 | }; 143 | 144 | handleNodeShapeClick = shape => (event) => { 145 | event.stopPropagation(); 146 | this.props.onNodeShapeClick(event, shape); 147 | }; 148 | 149 | handleNodeShapeDragStart = shape => (event) => { 150 | this.props.onNodeShapeDragStart(event, shape); 151 | }; 152 | 153 | handleNodeShapeDragEnd = shape => (event) => { 154 | this.props.onNodeShapeDragEnd(event); 155 | } 156 | 157 | render() { 158 | const { classes } = this.props; 159 | const { expanded } = this.state; 160 | 161 | return ( 162 |
163 | {nodeShapeCategories.map((nodeShapeCategory) => 164 | 169 | }> 170 | {nodeShapeCategory.name} 171 | 172 | 173 | {nodeShapeCategory.shapes.map((shape) => 174 |
184 |
185 | )} 186 |
187 |
188 | )} 189 |
190 | ); 191 | } 192 | } 193 | 194 | InsertPanels.propTypes = { 195 | classes: PropTypes.object.isRequired, 196 | }; 197 | 198 | export default withStyles(InsertPanels, styles); 199 | -------------------------------------------------------------------------------- /src/KeyboardShortcutsDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import withRoot from './withRoot.js'; 5 | import { IconButton } from '@mui/material'; 6 | import { Dialog } from '@mui/material'; 7 | import { DialogContent } from '@mui/material'; 8 | import { DialogContentText } from '@mui/material'; 9 | import { DialogTitle } from '@mui/material'; 10 | import { Table } from '@mui/material'; 11 | import { TableBody } from '@mui/material'; 12 | import { TableCell } from '@mui/material'; 13 | import { TableRow } from '@mui/material'; 14 | import { Close as CloseIcon } from '@mui/icons-material'; 15 | 16 | const keyboardShortcuts = [ 17 | {key: 'Ctrl-A', description: 'Select all nodes and edges.'}, 18 | {key: 'Ctrl-Shift-A', description: 'Select all edges.'}, 19 | {key: 'Ctrl-C', description: 'Copy the selected node.'}, 20 | {key: 'Ctrl-V', description: 'Paste the cut/copied node.'}, 21 | {key: 'Ctrl-X', description: 'Cut the selected node.'}, 22 | {key: 'Ctrl-Y', description: 'Redo. Reimplement the last DOT source change.'}, 23 | {key: 'Ctrl-Z', description: 'Undo. Revert the last DOT source change.'}, 24 | {key: 'DEL', description: 'Delete the selected nodes and edges.'}, 25 | {key: 'ESC', description: 'De-select the selected nodes and edges. Abort the current drawing operation.'}, 26 | {key: 'f', description: 'Toggle fullscreen graph mode.'}, 27 | {key: '?', description: 'Show keyboard shortcuts.'}, 28 | ]; 29 | 30 | const styles = theme => ({ 31 | title: { 32 | display: 'flex', 33 | justifyContent: 'space-between', 34 | }, 35 | table: { 36 | marginBottom: theme.spacing(2), 37 | }, 38 | }); 39 | 40 | class KeyboardShortcutsDialog extends React.Component { 41 | 42 | handleClose = () => { 43 | this.props.onKeyboardShortcutsDialogClose(); 44 | }; 45 | 46 | render() { 47 | const { classes } = this.props; 48 | return ( 49 |
50 | 56 |
57 | Keyboard shortcuts in the graph 58 | 63 | 64 | 65 |
66 | 67 | 68 | 69 | {keyboardShortcuts.map(keyboardShortcut => { 70 | return ( 71 | 72 | 73 | {keyboardShortcut.key} 74 | 75 | 76 | {keyboardShortcut.description} 77 | 78 | 79 | ); 80 | })} 81 | 82 |
83 | 84 | For keyboard shortcuts in the text editor, please visit Ace Default Keyboard Shortcuts 85 | 86 |
87 |
88 |
89 | ); 90 | } 91 | } 92 | 93 | KeyboardShortcutsDialog.propTypes = { 94 | classes: PropTypes.object.isRequired, 95 | }; 96 | 97 | export default withRoot(withStyles(KeyboardShortcutsDialog, styles)); 98 | -------------------------------------------------------------------------------- /src/MainMenu.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Menu } from '@mui/material'; 4 | import { MenuItem } from '@mui/material'; 5 | 6 | class MainMenu extends React.Component { 7 | 8 | handleClose = () => { 9 | this.props.onMenuClose(); 10 | }; 11 | 12 | handleSettings = () => { 13 | this.props.onMenuClose(); 14 | this.props.onSettingsClick(); 15 | }; 16 | 17 | handleNew = () => { 18 | this.props.onMenuClose(); 19 | this.props.onNewClick(); 20 | }; 21 | 22 | handleOpenFromBrowser = () => { 23 | this.props.onMenuClose(); 24 | this.props.onOpenFromBrowserClick(); 25 | }; 26 | 27 | handleSaveAsToBrowser = () => { 28 | this.props.onMenuClose(); 29 | this.props.onSaveAsToBrowserClick(); 30 | }; 31 | 32 | handleRename = () => { 33 | this.props.onMenuClose(); 34 | this.props.onRenameClick(); 35 | }; 36 | 37 | handleExportAsUrl = () => { 38 | this.props.onMenuClose(); 39 | this.props.onExportAsUrlClick(); 40 | }; 41 | 42 | handleExportAsSvg = () => { 43 | this.props.onMenuClose(); 44 | this.props.onExportAsSvgClick(); 45 | }; 46 | 47 | render() { 48 | 49 | return ( 50 |
51 | 57 | New 58 | Open from browser 59 | Save as to browser 60 | Rename 61 | Export as URL 62 | Export as SVG 63 | Settings 64 | 65 |
66 | ); 67 | } 68 | } 69 | 70 | MainMenu.propTypes = { 71 | onMenuClose: PropTypes.func.isRequired, 72 | onSettingsClick: PropTypes.func.isRequired, 73 | onNewClick: PropTypes.func.isRequired, 74 | onOpenFromBrowserClick: PropTypes.func.isRequired, 75 | onSaveAsToBrowserClick: PropTypes.func.isRequired, 76 | onRenameClick: PropTypes.func.isRequired, 77 | onExportAsUrlClick: PropTypes.func.isRequired, 78 | onExportAsSvgClick: PropTypes.func.isRequired, 79 | anchorEl: PropTypes.object.isRequired, 80 | }; 81 | 82 | export default MainMenu; 83 | -------------------------------------------------------------------------------- /src/MouseOperationsDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import withRoot from './withRoot.js'; 5 | import { IconButton } from '@mui/material'; 6 | import { Dialog } from '@mui/material'; 7 | import { DialogContent } from '@mui/material'; 8 | import { DialogTitle } from '@mui/material'; 9 | import { Table } from '@mui/material'; 10 | import { TableBody } from '@mui/material'; 11 | import { TableCell } from '@mui/material'; 12 | import { TableRow } from '@mui/material'; 13 | import { Close as CloseIcon } from '@mui/icons-material'; 14 | 15 | const mouseOperations = [ 16 | {key: 'Mouse wheel', description: 'Zoom in or out.'}, 17 | {key: 'Double-click the canvas', description: 'Zoom in.'}, 18 | {key: 'Ctrl-drag the canvas', description: 'Pan the graph.'}, 19 | {key: 'Click a node or an edge', description: 'Select the node or an edge.'}, 20 | {key: 'Shift/Ctrl-click a node or an edge', description: 'Add the node or an edge to selection.'}, 21 | {key: 'Drag the canvas', description: 'Select the nodes and edges within the dragged area.'}, 22 | {key: 'Shift-drag the canvas', description: 'Add the nodes and edges within the dragged area to the selection.'}, 23 | {key: 'Right-click a node', description: 'Start drawing an edge from the node.'}, 24 | {key: 'Double-click a node', description: 'Connect the edge being drawn to the node.'}, 25 | {key: 'Middle-click the canvas', description: 'Insert a node with the latest used shape and attributes.'}, 26 | {key: 'Shift-middle-click the canvas', description: 'Insert a node with the latest inserted shape and default attributes.'}, 27 | {key: 'Click an insert shape', description: 'Insert a node from the insert panel with default attributes.'}, 28 | {key: 'Drag-and-drop an insert shape', description: 'Insert a node from the insert panel with default attributes.'}, 29 | ]; 30 | 31 | const styles = theme => ({ 32 | title: { 33 | display: 'flex', 34 | justifyContent: 'space-between', 35 | }, 36 | }); 37 | 38 | class MouseOperationsDialog extends React.Component { 39 | 40 | handleClose = () => { 41 | this.props.onMouseOperationsDialogClose(); 42 | }; 43 | 44 | render() { 45 | const { classes } = this.props; 46 | return ( 47 |
48 | 54 |
55 | Mouse operations in the graph 56 | 61 | 62 | 63 |
64 | 65 | 66 | 67 | {mouseOperations.map(mouseOperation => { 68 | return ( 69 | 70 | 71 | {mouseOperation.key} 72 | 73 | 74 | {mouseOperation.description} 75 | 76 | 77 | ); 78 | })} 79 | 80 |
81 |
82 |
83 |
84 | ); 85 | } 86 | } 87 | 88 | MouseOperationsDialog.propTypes = { 89 | classes: PropTypes.object.isRequired, 90 | }; 91 | 92 | export default withRoot(withStyles(MouseOperationsDialog, styles)); 93 | -------------------------------------------------------------------------------- /src/OpenFromBrowserDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import withRoot from './withRoot.js'; 5 | import { Close as CloseIcon } from '@mui/icons-material'; 6 | import { IconButton } from '@mui/material'; 7 | import { Button } from '@mui/material'; 8 | import { Dialog } from '@mui/material'; 9 | import { DialogContent } from '@mui/material'; 10 | import { DialogContentText } from '@mui/material'; 11 | import { DialogTitle } from '@mui/material'; 12 | import { DialogActions } from '@mui/material'; 13 | import { Table } from '@mui/material'; 14 | import { TableHead } from '@mui/material'; 15 | import { TableRow } from '@mui/material'; 16 | import { TableCell } from '@mui/material'; 17 | import { TableBody } from '@mui/material'; 18 | import { TableSortLabel } from '@mui/material'; 19 | import { Tooltip } from '@mui/material'; 20 | import moment from 'moment'; 21 | import { Delete as DeleteIcon } from '@mui/icons-material'; 22 | import DoYouWantToDeleteDialog from './DoYouWantToDeleteDialog.js'; 23 | import SvgPreview from './SvgPreview.js'; 24 | import DotSrcPreview from './DotSrcPreview.js'; 25 | 26 | const numLinesPreview = 5; 27 | 28 | function desc(a, b, orderBy) { 29 | if (b[orderBy] < a[orderBy]) { 30 | return -1; 31 | } 32 | if (b[orderBy] > a[orderBy]) { 33 | return 1; 34 | } 35 | return 0; 36 | } 37 | 38 | function stableSort(array, cmp) { 39 | const stabilizedThis = array.map((el, index) => [el, index]); 40 | stabilizedThis.sort((a, b) => { 41 | const order = cmp(a[0], b[0]); 42 | if (order === 0) { 43 | return a[1] - b[1]; 44 | } else { 45 | return order; 46 | } 47 | }); 48 | return stabilizedThis.map(el => el[0]); 49 | } 50 | 51 | function getSorting(order, orderBy) { 52 | return order === 'desc' ? (a, b) => desc(a, b, orderBy) : (a, b) => -desc(a, b, orderBy); 53 | } 54 | 55 | const rows = [ 56 | { id: 'name', numeric: false, disablePadding: true, label: 'Name' }, 57 | { id: 'dotSrc', numeric: false, disablePadding: false, label: 'DOT Source' }, 58 | { id: 'dotSrcLastChangeTime', numeric: false, disablePadding: false, label: 'Last Changed' }, 59 | { id: 'svg', numeric: false, disablePadding: false, label: 'Preview' }, 60 | { id: 'delete', numeric: false, disablePadding: false, label: 'Delete' }, 61 | ]; 62 | 63 | class EnhancedTableHead extends React.Component { 64 | createSortHandler = property => event => { 65 | this.props.onRequestSort(event, property); 66 | }; 67 | 68 | render() { 69 | const { order, orderBy } = this.props; 70 | 71 | return ( 72 | 73 | 74 | {rows.map(row => { 75 | return ( 76 | 82 | 87 | 93 | {row.label} 94 | 95 | 96 | 97 | ); 98 | }, this)} 99 | 100 | 101 | ); 102 | } 103 | } 104 | 105 | EnhancedTableHead.propTypes = { 106 | onRequestSort: PropTypes.func.isRequired, 107 | order: PropTypes.string.isRequired, 108 | orderBy: PropTypes.string.isRequired, 109 | }; 110 | 111 | const styles = theme => ({ 112 | root: { 113 | userSelect: 'none', 114 | }, 115 | title: { 116 | display: 'flex', 117 | justifyContent: 'space-between', 118 | }, 119 | table: { 120 | minWidth: 700, 121 | }, 122 | }); 123 | 124 | class OpenFromBrowserDialog extends React.Component { 125 | 126 | state = { 127 | selectedName: this.props.name, 128 | order: 'desc', 129 | orderBy: 'dotSrcLastChangeTime', 130 | } 131 | 132 | handleClose = () => { 133 | this.props.onClose(); 134 | }; 135 | 136 | handleClick = (name) => (event) =>{ 137 | this.setState({selectedName: name}); 138 | }; 139 | 140 | handleDoubleClick = (name) => (event) =>{ 141 | this.props.onOpen(name); 142 | }; 143 | 144 | handleOpen = (event) => { 145 | this.props.onOpen(this.state.selectedName); 146 | }; 147 | 148 | handleRequestSort = (event, property) => { 149 | const orderBy = property; 150 | let order = (property === 'dotSrcLastChangeTime' ? 'desc' : 'asc'); 151 | 152 | if (this.state.orderBy === property) { 153 | if (this.state.order === 'asc') { 154 | order = 'desc'; 155 | } else { 156 | order = 'asc'; 157 | } 158 | } 159 | 160 | this.setState({ order, orderBy }); 161 | }; 162 | 163 | handleConfirmedDelete = () => { 164 | this.setState({ 165 | doYouWantToDeleteDialogIsOpen: false, 166 | }); 167 | this.props.onDelete(this.state.deleteName); 168 | }; 169 | 170 | handleDelete = (name) => () => { 171 | this.setState({ 172 | doYouWantToDeleteDialogIsOpen: true, 173 | deleteName: name, 174 | }); 175 | }; 176 | 177 | handleDoYouWantToDeleteClose = () => { 178 | this.setState({ 179 | doYouWantToDeleteDialogIsOpen: false, 180 | }); 181 | } 182 | 183 | render() { 184 | const { classes } = this.props; 185 | const { orderBy } = this.state; 186 | const { order } = this.state; 187 | const projects = { 188 | ...this.props.projects, 189 | }; 190 | if (this.props.name) { 191 | projects[this.props.name] = { 192 | dotSrc: this.props.dotSrc, 193 | dotSrcLastChangeTime: this.props.dotSrcLastChangeTime, 194 | svg: this.props.svg, 195 | }; 196 | } 197 | const projectList = Object.keys(projects).map((name) => { 198 | const project = projects[name]; 199 | return { 200 | name: name, 201 | ...project, 202 | } 203 | }); 204 | const selectedName = projects[this.state.selectedName] ? this.state.selectedName : this.props.name; 205 | return ( 206 |
207 | 215 |
216 | Open graph from browser 217 | 218 | 219 | 220 |
221 | 222 | 223 | Open a graph from the browser's local storage. 224 | 225 | 226 | 231 | 232 | {stableSort(projectList, getSorting(order, orderBy)) 233 | .map((project) => { 234 | const name = project.name; 235 | return ( 236 | 243 | 244 | {name} 245 | 246 | 247 | 251 | 252 | 253 | {project.dotSrcLastChangeTime ? moment(project.dotSrcLastChangeTime).fromNow() : ''} 254 | 255 | 256 | 261 | 262 | 263 | 268 | 269 | 270 | 271 | 272 | ); 273 | })} 274 | 275 |
276 |
277 | 278 | 281 | 284 | 285 |
286 | {this.state.doYouWantToDeleteDialogIsOpen && 287 | 292 | } 293 |
294 | ); 295 | } 296 | } 297 | 298 | OpenFromBrowserDialog.propTypes = { 299 | classes: PropTypes.object.isRequired, 300 | name: PropTypes.string.isRequired, 301 | dotSrc: PropTypes.string.isRequired, 302 | dotSrcLastChangeTime: PropTypes.number.isRequired, 303 | projects: PropTypes.object.isRequired, 304 | onClose: PropTypes.func.isRequired, 305 | onOpen: PropTypes.func.isRequired, 306 | }; 307 | 308 | export default withRoot(withStyles(OpenFromBrowserDialog, styles)); 309 | -------------------------------------------------------------------------------- /src/SaveAsToBrowserDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import withRoot from './withRoot.js'; 5 | import { Close as CloseIcon } from '@mui/icons-material'; 6 | import { IconButton } from '@mui/material'; 7 | import { Button } from '@mui/material'; 8 | import { Dialog } from '@mui/material'; 9 | import { DialogContent } from '@mui/material'; 10 | import { DialogContentText } from '@mui/material'; 11 | import { DialogTitle } from '@mui/material'; 12 | import { DialogActions } from '@mui/material'; 13 | import { TextField } from '@mui/material'; 14 | import DoYouWantToReplaceItDialog from './DoYouWantToReplaceItDialog.js'; 15 | 16 | const styles = theme => ({ 17 | title: { 18 | display: 'flex', 19 | justifyContent: 'space-between', 20 | }, 21 | content: { 22 | overflowY: 'visible', 23 | }, 24 | }); 25 | 26 | class SaveAsToBrowserDialog extends React.Component { 27 | 28 | constructor(props) { 29 | super(props); 30 | this.state = { 31 | doYouWantToReplaceItDialogIsOpen: false, 32 | }; 33 | this.name = this.props.defaultNewName; 34 | } 35 | 36 | handleClose = () => { 37 | this.props.onClose(); 38 | }; 39 | 40 | handleChange = (event) => { 41 | this.name = event.target.value; 42 | }; 43 | 44 | handleKeyPress = (event) => { 45 | if (event.key === 'Enter') { 46 | this.handleSave(); 47 | } 48 | }; 49 | 50 | handleSave = () => { 51 | const newName = this.name; 52 | const currentName = this.props.name; 53 | if (this.props.projects[newName] == null || newName === currentName) { 54 | this.handleConfirmedSave(); 55 | } else { 56 | this.setState({ 57 | doYouWantToReplaceItDialogIsOpen: true, 58 | replaceName: newName, 59 | }); 60 | } 61 | }; 62 | 63 | handleConfirmedSave = () => { 64 | this.setState({ 65 | doYouWantToReplaceItDialogIsOpen: false, 66 | }); 67 | this.props.onSave(this.name); 68 | }; 69 | 70 | handleDoYouWantToReplaceItClose = () => { 71 | this.setState({ 72 | doYouWantToReplaceItDialogIsOpen: false, 73 | }); 74 | } 75 | 76 | render() { 77 | const { classes } = this.props; 78 | return ( 79 |
80 | 87 |
88 | 89 | {this.props.rename ? 'Rename graph' : 'Save graph to browser'} 90 | 91 | 92 | 93 | 94 |
95 | 96 | 97 | {this.props.rename ? 98 | "Give the current graph a new name in the browser's local storage." : 99 | "Save a the current graph to the browser's local storage under a new name." 100 | } 101 | 102 | 113 | 114 | 115 | 118 | 121 | 122 |
123 | {this.state.doYouWantToReplaceItDialogIsOpen && 124 | 129 | } 130 |
131 | ); 132 | } 133 | } 134 | 135 | SaveAsToBrowserDialog.propTypes = { 136 | classes: PropTypes.object.isRequired, 137 | onSave: PropTypes.func.isRequired, 138 | onClose: PropTypes.func.isRequired, 139 | name: PropTypes.string.isRequired, 140 | defaultNewName: PropTypes.string.isRequired, 141 | projects: PropTypes.object.isRequired, 142 | }; 143 | 144 | export default withRoot(withStyles(SaveAsToBrowserDialog, styles)); 145 | -------------------------------------------------------------------------------- /src/SettingsDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from 'tss-react/mui'; 4 | import withRoot from './withRoot.js'; 5 | import { IconButton } from '@mui/material'; 6 | import { Dialog } from '@mui/material'; 7 | import { DialogContent } from '@mui/material'; 8 | import { DialogContentText } from '@mui/material'; 9 | import { DialogTitle } from '@mui/material'; 10 | import { FormGroup } from '@mui/material'; 11 | import { FormControlLabel } from '@mui/material'; 12 | import { FormLabel } from '@mui/material'; 13 | import { RadioGroup } from '@mui/material'; 14 | import { Radio } from '@mui/material'; 15 | import { Switch } from '@mui/material'; 16 | import { Input } from '@mui/material'; 17 | import { InputAdornment } from '@mui/material'; 18 | import { InputLabel } from '@mui/material'; 19 | import { MenuItem } from '@mui/material'; 20 | import { FormHelperText } from '@mui/material'; 21 | import { FormControl } from '@mui/material'; 22 | import { Select } from '@mui/material'; 23 | import { Close as CloseIcon } from '@mui/icons-material'; 24 | 25 | const engines = [ 26 | 'circo', 27 | 'dot', 28 | 'fdp', 29 | 'neato', 30 | 'osage', 31 | 'patchwork', 32 | 'twopi', 33 | ]; 34 | 35 | const styles = theme => ({ 36 | root: { 37 | overflowY: 'visible', 38 | }, 39 | formControl: { 40 | margin: theme.spacing(1), 41 | minWidth: 120, 42 | }, 43 | formControlLabel: { 44 | margin: theme.spacing(-0.5), 45 | }, 46 | title: { 47 | display: 'flex', 48 | justifyContent: 'space-between', 49 | }, 50 | transitionDuration: { 51 | width: '7.6em', 52 | }, 53 | group: { 54 | marginTop: theme.spacing(1), 55 | marginLeft: theme.spacing(0), 56 | }, 57 | tweenPrecisionAbsoluteInput: { 58 | marginTop: theme.spacing(1), 59 | marginLeft: theme.spacing(1.5), 60 | width: '6.9em', 61 | }, 62 | tweenPrecisionRelativeInput: { 63 | marginTop: theme.spacing(1), 64 | marginLeft: theme.spacing(1.5), 65 | width: '4.8em', 66 | }, 67 | holdOffInput: { 68 | width: '7.6em', 69 | }, 70 | fontSizeInput: { 71 | width: '5em', 72 | }, 73 | tabSizeInput: { 74 | width: '7.1em', 75 | }, 76 | }); 77 | 78 | class SettingsDialog extends React.Component { 79 | 80 | handleClose = () => { 81 | this.props.onSettingsClose(); 82 | }; 83 | 84 | handleEngineSelectChange = (event) => { 85 | this.props.onEngineSelectChange(event.target.value); 86 | }; 87 | 88 | handleFitSwitchChange = (event) => { 89 | this.props.onFitGraphSwitchChange(event.target.checked); 90 | }; 91 | 92 | handleTransitionDurationChange = (event) => { 93 | this.props.onTransitionDurationChange(event.target.value); 94 | }; 95 | 96 | handleTweenPathsSwitchChange = (event) => { 97 | this.props.onTweenPathsSwitchChange(event.target.checked); 98 | }; 99 | 100 | handleTweenShapesSwitchChange = (event) => { 101 | this.props.onTweenShapesSwitchChange(event.target.checked); 102 | }; 103 | 104 | handleTweenPrecisionChange = (event) => { 105 | let tweenPrecision = event.target.value; 106 | if (event.target.value === 'absolute' || tweenPrecision > 1) { 107 | tweenPrecision = Math.max(Math.ceil(tweenPrecision), 1); 108 | } 109 | this.props.onTweenPrecisionChange(tweenPrecision.toString() + (this.props.tweenPrecision.includes('%') ? '%': '')); 110 | }; 111 | 112 | handleTweenPrecisionIsRelativeRadioChange = (event) => { 113 | let tweenPrecision = +this.props.tweenPrecision.split('%')[0]; 114 | if (event.target.value === 'absolute' || tweenPrecision > 1) { 115 | tweenPrecision = Math.max(Math.ceil(tweenPrecision), 1); 116 | } 117 | this.props.onTweenPrecisionChange(tweenPrecision.toString() + (event.target.value === 'relative' ? '%': '')); 118 | }; 119 | 120 | handleHoldOffChange = (event) => { 121 | this.props.onHoldOffChange(event.target.value); 122 | }; 123 | 124 | handleFontSizeChange = (event) => { 125 | this.props.onFontSizeChange(event.target.value); 126 | }; 127 | 128 | handleTabSizeChange = (event) => { 129 | this.props.onTabSizeChange(event.target.value); 130 | }; 131 | 132 | render() { 133 | const { classes } = this.props; 134 | const tweenPrecisionIsRelative = this.props.tweenPrecision.includes('%'); 135 | const tweenPrecision = +this.props.tweenPrecision.split('%')[0]; 136 | const tweenPrecisionType = tweenPrecisionIsRelative ? 'relative' : 'absolute'; 137 | const tweenPrecisionUnit = tweenPrecisionIsRelative ? '%' : 'points'; 138 | const enableTweenPrecisionSetting = this.props.tweenPaths || this.props.tweenShapes; 139 | const tweenPrecisionStep = (tweenPrecisionIsRelative && tweenPrecision <= 1) ? 0.1 : 1; 140 | const tweenPrecisionInputClass = tweenPrecisionIsRelative ? classes.tweenPrecisionRelativeInput : classes.tweenPrecisionAbsoluteInput; 141 | return ( 142 |
143 | 150 |
151 | Graph rendering 152 | 153 | 154 | 155 |
156 | 157 | 158 | These settings affects how the graph is rendered. 159 | 160 | 161 | Engine 162 | 178 | Graphviz layout engine 179 | 180 | 181 | Graph viewing 182 | 183 | 184 | These settings affects how the graph is viewed. They do not affect the graph itself. 185 | 186 | 187 | 195 | } 196 | label="Fit graph to available area" 197 | /> 198 | 199 | 203 | Transition duration 204 | seconds} 211 | inputProps={{ 212 | 'aria-label': 'transitionDuration', 213 | min: 0.1, 214 | max: 99, 215 | step: 0.1, 216 | }} 217 | /> 218 | 219 | 220 | 228 | } 229 | label="Enable path tweening during transitions" 230 | /> 231 | 232 | 233 | 241 | } 242 | label="Enable shape tweening during transitions" 243 | /> 244 | 245 | 250 | Tweening precision 251 | 258 | } 263 | label="Absolute" 264 | /> 265 | } 270 | label="Relative" 271 | /> 272 | 273 | {tweenPrecisionUnit} } 281 | inputProps={{ 282 | 'aria-label': 'tweenPrecision', 283 | min: tweenPrecisionStep, 284 | max: tweenPrecisionIsRelative ? 100 : 999, 285 | step: tweenPrecisionStep, 286 | }} 287 | /> 288 | 289 | 290 | Text Editor 291 | 292 | 296 | Font size 297 | px} 304 | inputProps={{ 305 | 'aria-label': 'FontSize', 306 | min: 1, 307 | max: 99, 308 | step: 1, 309 | }} 310 | /> 311 | 312 | 313 | 314 | 318 | Tab size 319 | spaces} 326 | inputProps={{ 327 | 'aria-label': 'TabSize', 328 | min: 1, 329 | max: 99, 330 | step: 1, 331 | }} 332 | /> 333 | 334 | 335 | 336 | 340 | Hold-off time 341 | seconds} 348 | inputProps={{ 349 | 'aria-label': 'Holdoff', 350 | min: 0.0, 351 | max: 9.9, 352 | step: 0.1, 353 | }} 354 | /> 355 | Time of editor inactivity after which graph rendering starts 356 | 357 | 358 |
359 |
360 | ); 361 | } 362 | } 363 | 364 | SettingsDialog.propTypes = { 365 | classes: PropTypes.object.isRequired, 366 | }; 367 | 368 | export default withRoot(withStyles(SettingsDialog, styles)); 369 | -------------------------------------------------------------------------------- /src/SvgPreview.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useState } from 'react'; 3 | import { useEffect } from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import { Card } from '@mui/material'; 6 | import { CardContent } from '@mui/material'; 7 | import { withStyles } from 'tss-react/mui'; 8 | import { useTheme } from '@mui/material'; 9 | 10 | const previewWidth = 400; 11 | const previewHeight = 250; 12 | const previewMarginUnits = 1; 13 | const previewPadUnits = 0.5; 14 | 15 | const styles = theme => ({ 16 | card: { 17 | position: 'fixed', 18 | zIndex: 1, 19 | width: previewWidth + theme.spacing(previewPadUnits * 2).replace('px', ''), 20 | height: previewHeight + theme.spacing(previewPadUnits * 2).replace('px', ''), 21 | }, 22 | cardContent: { 23 | padding: theme.spacing(previewPadUnits), 24 | 25 | }, 26 | }); 27 | 28 | const SvgPreview = ({ classes, svg, width, height }) => { 29 | 30 | const [preview, setPreview] = useState(false); 31 | const [x, setX] = useState(0); 32 | const [y, setY] = useState(0); 33 | 34 | let divPreview; 35 | let divThumbnail; 36 | 37 | useEffect(() => { 38 | const svgThumbnail = divThumbnail.querySelector('svg'); 39 | if (svgThumbnail) { 40 | svgThumbnail.setAttribute('width', width); 41 | svgThumbnail.setAttribute('height', height); 42 | const g = svgThumbnail.querySelector('g'); 43 | g.addEventListener('mouseenter', handleMouseEnter); 44 | g.addEventListener('mouseleave', handleMouseOut); 45 | } 46 | if (divPreview) { 47 | const svgPreview = divPreview.querySelector('svg'); 48 | svgPreview.setAttribute('width', previewWidth); 49 | svgPreview.setAttribute('height', previewHeight); 50 | } 51 | }); 52 | 53 | const handleMouseEnter = (event) => { 54 | setPreview(true); 55 | setX(event.clientX); 56 | setY(event.clientY); 57 | }; 58 | 59 | const handleMouseOut = (event) => { 60 | setPreview(false); 61 | }; 62 | 63 | const theme = useTheme(); 64 | const previewMargin = +theme.spacing(previewMarginUnits).replace('px', ''); 65 | 66 | return ( 67 | 68 |
divThumbnail = div} 71 | dangerouslySetInnerHTML={{__html: svg}} 72 | > 73 |
74 | {preview && 75 | 84 | 85 |
divPreview = div} 87 | dangerouslySetInnerHTML={{__html: svg}} 88 | > 89 |
90 |
91 |
92 | } 93 |
94 | ); 95 | } 96 | 97 | SvgPreview.propTypes = { 98 | svg: PropTypes.string.isRequired, 99 | width: PropTypes.string.isRequired, 100 | height: PropTypes.string.isRequired, 101 | classes: PropTypes.object.isRequired, 102 | }; 103 | 104 | export default withStyles(SvgPreview, styles); 105 | -------------------------------------------------------------------------------- /src/TextEditor.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withStyles } from 'tss-react/mui'; 3 | import ace from 'react-ace'; 4 | const AceEditor = typeof ace == 'function' ? ace : ace.default; 5 | import 'ace-builds/src-noconflict/mode-dot.js'; 6 | import 'ace-builds/src-noconflict/theme-github.js'; 7 | import 'ace-builds/src-noconflict/ext-searchbox.js'; 8 | import { IconButton } from '@mui/material'; 9 | import { ErrorOutline } from '@mui/icons-material'; 10 | 11 | const styles = { 12 | errorButton: { 13 | position: 'absolute', 14 | top: 'calc(64px + 12px)', 15 | }, 16 | aceSelectedWord: { 17 | position: 'absolute', 18 | background: '#d8f4fd', 19 | border: '1px solid #02c1ff', 20 | }, 21 | }; 22 | 23 | class TextEditor extends React.Component { 24 | 25 | constructor(props) { 26 | super(props); 27 | this.pendingChanges = 0; 28 | } 29 | 30 | handleChange = (value, event) => { 31 | const hasUndo = this.editor.getSession().getUndoManager().hasUndo(); 32 | const hasRedo = this.editor.getSession().getUndoManager().hasRedo(); 33 | const undoRedoState = {hasUndo, hasRedo}; 34 | this.props.onTextChange(value, undoRedoState); 35 | }; 36 | 37 | handleBeforeLoad = (ace) => { 38 | this.ace = ace; 39 | }; 40 | 41 | handleLoad = (editor) => { 42 | this.editor = editor; 43 | this.props.registerUndo(this.undo); 44 | this.props.registerRedo(this.redo); 45 | this.props.registerUndoReset(this.resetUndoStack); 46 | }; 47 | 48 | handleErrorButtonClick = (event) => { 49 | this.editor.scrollToLine(this.props.error.line - 1, true); 50 | }; 51 | 52 | undo = () => { 53 | this.editor.getSession().getUndoManager().undo(); 54 | } 55 | 56 | redo = () => { 57 | this.editor.getSession().getUndoManager().redo(); 58 | } 59 | 60 | resetUndoStack = () => { 61 | this.editor.getSession().getUndoManager().reset(); 62 | } 63 | 64 | render() { 65 | const { classes } = this.props; 66 | var annotations = null; 67 | if (this.props.error) { 68 | annotations = [{ 69 | row: this.props.error.line - 1, 70 | column: 0, 71 | text: this.props.error.message, 72 | type: "error", 73 | dummy: Date.now(), // Workaround for issue #33 74 | }]; 75 | if (this.editor && !this.editor.isRowFullyVisible(this.props.error.line)) { 76 | if (!this.prevError || 77 | this.props.error.message !== this.prevError.message || 78 | (this.props.error.line !== this.prevError.line && 79 | this.props.error.numLines - this.props.error.line !== this.prevNumLines - this.prevError.line) 80 | ) { 81 | this.editor.scrollToLine(this.props.error.line - 1, true); 82 | } 83 | } 84 | this.prevNumLines = this.props.error.numLines; 85 | } 86 | this.prevError = this.props.error; 87 | const locations = this.props.selectedGraphComponents.reduce( 88 | (locations, component) => locations.concat( 89 | component.locations 90 | ), 91 | [] 92 | ); 93 | const markers = locations.map((location) => ({ 94 | startRow: location.start.line - 1, 95 | startCol: location.start.column - 1, 96 | endRow: location.end.line - 1, 97 | endCol: location.end.column - 1, 98 | className: classes.aceSelectedWord, 99 | type: 'background', 100 | })); 101 | // FIXME: There must be a better way... 102 | let scrollbarWidth = 0; 103 | if (this.div) { 104 | const scrollbarDiv = this.div.querySelector('div.ace_scrollbar-v'); 105 | const hasScrollbar = scrollbarDiv && scrollbarDiv.style['display'] !== 'none'; 106 | if (hasScrollbar) { 107 | const scrollbarInnerDiv = scrollbarDiv.querySelector('div.ace_scrollbar-inner'); 108 | scrollbarWidth = scrollbarInnerDiv.clientWidth - 5; 109 | } 110 | } 111 | return ( 112 |
this.div = div}> 113 | 139 | 151 | 152 | 153 |
154 | ); 155 | } 156 | } 157 | 158 | export default withStyles(TextEditor, styles); 159 | -------------------------------------------------------------------------------- /src/UpdatedSnackbar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Close as CloseIcon } from '@mui/icons-material'; 4 | import { IconButton } from '@mui/material'; 5 | import { Snackbar } from '@mui/material'; 6 | import { SnackbarContent } from '@mui/material'; 7 | import { withStyles } from 'tss-react/mui'; 8 | import withRoot from './withRoot.js'; 9 | import packageJSON from '../package.json'; 10 | import versions from './versions.json'; 11 | import graphvizVersions from './graphviz-versions.json'; 12 | 13 | const styles = theme => ({ 14 | snackbar: { 15 | "display": "block", 16 | "margin-top": "48px", 17 | "max-width": "none", 18 | "width": "100%", 19 | }, 20 | content: { 21 | "backgroundColor": theme.palette.secondary.dark, 22 | "max-width": "inherit", 23 | "width": "inherit", 24 | } 25 | }); 26 | 27 | class UpdatedSnackbar extends React.Component { 28 | 29 | handleClose = () => { 30 | this.props.onUpdatedSnackbarClose(); 31 | }; 32 | 33 | render() { 34 | const { classes } = this.props; 35 | const version = packageJSON.version; 36 | const changelogHeaderId = (() => { 37 | if (versions[version] == null) { 38 | return version; 39 | } 40 | else { 41 | const releaseDate = versions[version].release_date; 42 | return version.replace(/\./g, '') + "---" + releaseDate; 43 | } 44 | })(); 45 | const graphvizVersion = this.props.graphvizVersion; 46 | const graphvizReleaseDate = graphvizVersions[graphvizVersion].release_date; 47 | const graphvizChangelogHeaderId = graphvizVersion.replace(/\./g, '') + "--" + graphvizReleaseDate; 48 | return ( 49 | 58 | 63 | The Graphviz Visual Editor has been updated to version 64 | {' '} 65 | 71 | {version} 72 | 73 | . The underlying Graphviz software 74 | {this.props.newGraphvizVersion && ` has been updated to `} 75 | {!this.props.newGraphvizVersion && ` is still `} 76 | version 77 | {' '} 78 | 84 | {graphvizVersion} 85 | 86 | . 87 | } 88 | action={[ 89 | 96 | 97 | , 98 | ] 99 | } 100 | /> 101 | 102 | ); 103 | } 104 | } 105 | 106 | UpdatedSnackbar.propTypes = { 107 | classes: PropTypes.object.isRequired, 108 | }; 109 | 110 | export default withRoot(withStyles(UpdatedSnackbar, styles)); 111 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import Index from './pages/index.js'; 3 | 4 | const root = createRoot(document.querySelector('#root')); 5 | root.render(); 6 | -------------------------------------------------------------------------------- /src/test-utils/polyfillElement.js: -------------------------------------------------------------------------------- 1 | export default function polyfillElement(properties) { 2 | 3 | Object.keys(properties).forEach((propertyKey) => { 4 | const propertyValue = properties[propertyKey]; 5 | Object.defineProperty(window.Element.prototype, propertyKey, { 6 | get: function() { 7 | return propertyValue; 8 | } 9 | }); 10 | }); 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/test-utils/polyfillFetch.js: -------------------------------------------------------------------------------- 1 | import {promises} from "fs"; 2 | 3 | export default function polyfillFetch() { 4 | global.fetch = function(filename) { 5 | return promises.open(filename, 'r').then((filehandle) => { 6 | return filehandle.readFile().then(data => { 7 | return { 8 | ok: true, 9 | arrayBuffer: () => data, 10 | }; 11 | }); 12 | }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test-utils/polyfillSVGElement.js: -------------------------------------------------------------------------------- 1 | export default function polyfillSVGElement() { 2 | 3 | window.SVGElement.prototype.getPointAtLength = function (distance) { 4 | if (this.nodeName != 'path') { 5 | throw 'jsdom.js: getPointAtLength: unexpected element ' + this.nodeName; 6 | } 7 | return { 8 | x: distance * 100.0, 9 | y: distance * 100.0, 10 | } 11 | } 12 | window.SVGElement.prototype.getTotalLength = function () { 13 | if (this.nodeName != 'path') { 14 | throw 'jsdom.js: getTotalLength: unexpected element ' + this.nodeName; 15 | } 16 | return 100.0; 17 | } 18 | window.SVGElement.prototype.getBBox = function () { 19 | 20 | if (this.getAttribute('points')) { 21 | var points = this.getAttribute('points').split(' '); 22 | var x = points.map(function(p) {return +p.split(',')[0]}); 23 | var y = points.map(function(p) {return +p.split(',')[1]}); 24 | var xmin = Math.min.apply(null, x); 25 | var xmax = Math.max.apply(null, x); 26 | var ymin = Math.min.apply(null, y); 27 | var ymax = Math.max.apply(null, y); 28 | } else if (this.getAttribute('cx')) { 29 | var cx = +this.getAttribute('cx'); 30 | var cy = +this.getAttribute('cy'); 31 | var rx = +this.getAttribute('rx'); 32 | var ry = +this.getAttribute('ry'); 33 | var xmin = cx - rx; 34 | var xmax = cx + rx; 35 | var ymin = cy - ry; 36 | var ymax = cy + ry; 37 | } else if (this.getAttribute('x')) { 38 | var x = +this.getAttribute('x'); 39 | var y = +this.getAttribute('y'); 40 | var xmin = x; 41 | var xmax = x + 0; 42 | var ymin = y; 43 | var ymax = y + 0; 44 | } else if (this.getAttribute('d')) { 45 | var d = this.getAttribute('d'); 46 | var points = d.split(/[A-Z ]/); 47 | points.shift(); 48 | var x = points.map(function(p) {return +p.split(',')[0]}); 49 | var y = points.map(function(p) {return +p.split(',')[1]}); 50 | var xmin = Math.min.apply(null, x); 51 | var xmax = Math.max.apply(null, x); 52 | var ymin = Math.min.apply(null, y); 53 | var ymax = Math.max.apply(null, y); 54 | } else if (this.nodeName === 'g' && this.attributes[0].name === 'id' && this.attributes[0].value === 'graph0') { 55 | const polygon = this.querySelector('polygon'); 56 | var x = +polygon.getAttribute('x'); 57 | var y = +polygon.getAttribute('y'); 58 | var xmin = x; 59 | var xmax = x + 0; 60 | var ymin = y; 61 | var ymax = y + 0; 62 | } else if (this.nodeName === 'g' && this.attributes[1].name === 'class' && this.attributes[1].value == 'node') { 63 | const shape = this.querySelector('ellipse,polygon,path'); 64 | return shape.getBBox(); 65 | } else if (this.nodeName === 'g' && this.attributes[1].name === 'class' && this.attributes[1].value == 'edge') { 66 | const shape = this.querySelector('path'); 67 | return shape.getBBox(); 68 | } else { 69 | throw "WTF!" + this; 70 | } 71 | var bbox = { 72 | x: xmin, 73 | y: ymin, 74 | width: xmax - xmin, 75 | height: ymax - ymin, 76 | }; 77 | return bbox; 78 | } 79 | window.SVGElement.prototype.getCTM = function () { 80 | if (this.nodeName != 'g') { 81 | throw 'jsdom.js: getCTM: unexpected element ' + this.nodeName; 82 | } 83 | return { 84 | a: 1, 85 | }; 86 | } 87 | if (!('width' in window.SVGElement.prototype)) { 88 | Object.defineProperty(window.SVGElement.prototype, 'width', { 89 | get: function() { 90 | return { 91 | baseVal: { 92 | value: +this.getAttribute('width').replace('pt', ''), 93 | } 94 | }; 95 | } 96 | }); 97 | } 98 | if (!('height' in window.SVGElement.prototype)) { 99 | Object.defineProperty(window.SVGElement.prototype, 'height', { 100 | get: function() { 101 | return { 102 | baseVal: { 103 | value: +this.getAttribute('height').replace('pt', ''), 104 | } 105 | }; 106 | } 107 | }); 108 | } 109 | if (!('transform' in window.SVGElement.prototype)) { 110 | Object.defineProperty(window.SVGElement.prototype, 'transform', { 111 | get: function() { 112 | if (this.getAttribute('transform')) { 113 | var translate = this.getAttribute('transform').replace(/.*translate\((-*[\d.]+[ ,]+-*[\d.]+)\).*/, function(match, xy) { 114 | return xy; 115 | }).split(/[ ,]+/).map(function(v) { 116 | return +v; 117 | }); 118 | var scale = this.getAttribute('transform').replace(/.*.*scale\((-*[\d.]+[ ,]*-*[\d.]*)\).*/, function(match, scale) { 119 | return scale; 120 | }).split(/[ ,]+/).map(function(v) { 121 | return +v; 122 | }); 123 | return { 124 | baseVal: { 125 | numberOfItems: 1, 126 | consolidate: function() { 127 | return { 128 | matrix: { 129 | 'a': scale[0], 130 | 'b': 0, 131 | 'c': 0, 132 | 'd': scale[1] || scale[0], 133 | 'e': translate[0], 134 | 'f': translate[1], 135 | } 136 | }; 137 | }, 138 | }, 139 | }; 140 | } else { 141 | return { 142 | baseVal: { 143 | numberOfItems: 0, 144 | consolidate: function() { 145 | return null; 146 | }, 147 | }, 148 | }; 149 | } 150 | }, 151 | }); 152 | } 153 | if (!('viewBox' in window.SVGElement.prototype)) { 154 | Object.defineProperty(window.SVGElement.prototype, 'viewBox', { 155 | get: function() { 156 | let viewBox = this.getAttribute('viewBox').split(' '); 157 | return { 158 | baseVal: { 159 | x: +viewBox[0], 160 | y: +viewBox[1], 161 | width: +viewBox[2], 162 | height: +viewBox[3], 163 | }, 164 | }; 165 | }, 166 | }); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/test-utils/polyfillXMLSerializer.js: -------------------------------------------------------------------------------- 1 | import XMLSerializer from '@harrison-ifeanyichukwu/xml-serializer'; 2 | 3 | export default function polyfillXMLSerializer() { 4 | window.XMLSerializer = XMLSerializer; 5 | } 6 | -------------------------------------------------------------------------------- /src/withRoot.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ThemeProvider, StyledEngineProvider, createTheme } from '@mui/material'; 3 | import { CssBaseline } from '@mui/material'; 4 | 5 | // A theme with custom primary and secondary color. 6 | // It's optional. 7 | const theme = createTheme({ 8 | palette: { 9 | primary: { 10 | main: "#4ed1f8", // Blueish 11 | }, 12 | secondary: { 13 | main: "#19ccaa", // Greenish 14 | }, 15 | }, 16 | components: { 17 | MuiCheckbox: { 18 | defaultProps: { 19 | color: "secondary", 20 | }, 21 | }, 22 | MuiRadio: { 23 | defaultProps: { 24 | color: "secondary", 25 | }, 26 | }, 27 | MuiSwitch: { 28 | defaultProps: { 29 | color: "secondary", 30 | }, 31 | }, 32 | }, 33 | }); 34 | 35 | function withRoot(Component) { 36 | function WithRoot(props) { 37 | // ThemeProvider makes the theme available down the React tree 38 | // thanks to React context. 39 | return ( 40 | 41 | 42 | {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | 50 | return WithRoot; 51 | } 52 | 53 | export default withRoot; 54 | --------------------------------------------------------------------------------