├── .gitignore ├── LICENSE ├── README.md ├── elm.json ├── html ├── built.js ├── css │ ├── dark-theme.css │ └── layout.css └── img │ ├── arrow-left.svg │ ├── bin.svg │ ├── copy.svg │ ├── energy-background.svg │ ├── favicon.png │ ├── logo.svg │ └── tidy.svg ├── index.html ├── modd.conf ├── package.json ├── readme └── browser-stack.png └── src ├── AutoLayout.elm ├── Build.elm ├── IdMap.elm ├── LinearDict.elm ├── Main.elm ├── Model.elm ├── Parse.elm ├── Update.elm ├── Vec2.elm └── View.elm /.gitignore: -------------------------------------------------------------------------------- 1 | # elm-package generated files 2 | elm-stuff 3 | 4 | # elm-repl generated files 5 | repl-temp-* 6 | 7 | # intellij project files 8 | regex-nodes.iml 9 | .idea 10 | 11 | # file save watcher 12 | modd.exe 13 | 14 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Johannes Vollmer 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Regex Nodes 2 | 3 | [This node-based regular expression editor](https://johannesvollmer.github.io/regex-nodes/) 4 | helps you understand and edit regular expressions for use in your Javascript code. 5 | 6 | > If your regular expressions are complex enough to give this editor relevance, 7 | > you probably should consider not using regular expressions, haha. 8 | 9 | # Why Nodes? 10 | 11 | One of the problems with regular expressions is 12 | that they get quite messy very quickly. Operator 13 | precedence is not always obvious and can be misleading. 14 | Nodes, on the other hand, are a visual hierarchy. A text-based regex 15 | cannot simply be broken into several lines or indented, 16 | because that would alter the meaning of the expression. 17 | 18 | The other major benefit of nodes is that the editor will prevent you from 19 | producing invalid expressions. Other regex editors analyze the possibly incorrect 20 | regular expression that the user has come up with. The node editor will 21 | allow you to enter your intention and generate a correct regular expression. 22 | 23 | In addition, nodes offer various other advantages, such as 24 | reusing subexpressions, automatic character escaping, grouping and parameterizing expressions, 25 | and automatic optimizations. 26 | 27 | 28 | # Core Features 29 | - Construct regular expressions using a visual editor 30 | - Load existing regular expressions from your Javascript code into the editor and edit it utilizing nodes 31 | - Use the generated expression in Javascript 32 | - See effects of the regular expression live using a customizable example text 33 | - Coming Soon: Reuse common patterns to not spend time reinventing the regex wheel 34 | 35 | 36 | # How to use 37 | 38 | See [this blog post](https://johannesvollmer.github.io/2019/announcing-regex-nodes/). 39 | It explains how to handle the nodes and what the buttons do. 40 | 41 | # Build 42 | 43 | With elm installed on your system, run 44 | `elm make src/Main.elm --output=html/built.js`. Also, see 45 | [compiling elm with optimization enabled](https://elm-lang.org/0.19.0/optimize). 46 | 47 | Alternatively, use [modd](https://github.com/cortesi/modd) or `npm run watch` 48 | in this directory to compile on every file save. 49 | 50 | 51 | # Roadmap 52 | 1. As I have realized that node groups would not be worth development time 53 | right now, the editor should offer common regex patterns as hard-coded nodes. 54 | When parsing a regular expression, those patterns should be recognized. 55 | 2. To fully quality as an editor, the parser must support repetition ranges in curly 56 | braces and Unicode literals at all costs. 57 | 3. Simply connecting and rearranging properties of set nodes and sequence nodes. 58 | 59 | # Project Songs (archived here for future nostalgia) 60 | - Sorsari: Children of Gaia 61 | - Barnacle Boi: Downpour 62 | - Yedgar: Asura 63 | 64 | # Shoutout 65 | 66 | [![BrowserStack Logo](/readme/browser-stack.png?raw=true "BrowserStack")](https://www.browserstack.com/) 67 | 68 | Thanks to BrowserStack, we can make sure this website runs on any browser, for free. 69 | BrowserStack loves Open Source, and Open Source loves BrowserStack. 70 | 71 | 72 | # To Do 73 | - [ ] Add automated tests 74 | - [x] Example text for instant feedback 75 | - [x] Implement all node types 76 | - [x] Automatic node width calculation 77 | - [x] Initial node setup for an easy start 78 | - [x] After improving parsing, add a more interesting start setup 79 | - [x] Build Scripts + Build to Github Pages 80 | - [x] Use optimized builds instead of debug builds for github pages 81 | - [x] Use node width and property count when layouting parsed nodes 82 | Or use iterative physics approach (force-directed layout) 83 | - [ ] While in "Add Nodes", press enter to pick the first option 84 | - [x] Parse regex code in "Add Nodes" 85 | - [x] Charset `[abc]` 86 | - [x] Char ranges `[a-bc-d][^a-b]` 87 | - [ ] Fix composed negation being ignored 88 | - [x] Alternation `(a|b)` 89 | - [x] Escaped Characters `\W` 90 | - [ ] Unicode literals? `\x01` 91 | - [x] Sequences `the( |_)` 92 | - [x] Look Ahead `a(?!b)` 93 | - [x] Quantifiers `a?b{0,3}` 94 | - [x] `a?b??c+?d*?` 95 | - [x] `b{0,3} c{1,} d{5}` 96 | - [x] Positioning `(^, $)` 97 | - [x] How to delete nodes 98 | - [ ] Option: Delete with children 99 | - [ ] Option: When deleting a node, try to retain connections 100 | (If the deleted node is a single property node, 101 | connect the otherwise now opened connections) 102 | - [x] Do not adjust example for every node move 103 | - [x] Middle mouse button view movement 104 | - [x] Blur text input on non-middle-mouse-clicking anywhere 105 | - [x] Simplify UX of changing order in "Set Node"s 106 | - [x] Prevent cyclic connections 107 | - [x] Tooltips 108 | - [ ] Custom, styled tooltips? 109 | - [ ] Live Explanations!! 110 | - [ ] Move node including all input nodes? (Next to duplicate and delete) 111 | - [x] Iterative Auto-layout using physics simulation? 112 | - [ ] UX: Animate while holding button down instead of once per click 113 | - [ ] Reconnect replaced connections 114 | when reverting connection prototype 115 | - [x] Instantiate Nodes centered to the screen 116 | - [ ] Using the real window size 117 | - [ ] Consider rewriting Css to Sass 118 | - [ ] On input focus select container node 119 | - [ ] Premade Reusable Node Patterns! 120 | - [ ] UX: A node, which by default is collapsed, and on click 121 | reveals all connected nodes which are otherwise hidden 122 | (Think of it more like a section in a wiki which is summarized by the header) 123 | - [ ] Unicode in the output regex 124 | - [ ] Unicode literal node? 125 | - [ ] Support mobile devices 126 | - [x] Enable sharing node graphs by url? 127 | - [ ] Do not always update URI, but only when user wants to share? 128 | - [ ] Reset view button when panned too far away 129 | - [x] Deduplicate parsed node graph 130 | - [ ] Turn this whole project into a NodeJS monster just for testing and minification of the generated javascript.... or wait until a better solution arrives. 131 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "elm/browser": "1.0.2", 10 | "elm/core": "1.0.5", 11 | "elm/html": "1.0.0", 12 | "elm/json": "1.1.3", 13 | "elm/regex": "1.0.0", 14 | "elm/svg": "1.0.1", 15 | "elm/url": "1.0.0", 16 | "mpizenberg/elm-pointer-events": "4.0.2", 17 | "rtfeldman/elm-css": "16.1.1", 18 | "truqu/elm-base64": "2.0.4" 19 | }, 20 | "indirect": { 21 | "elm/bytes": "1.0.8", 22 | "elm/file": "1.0.5", 23 | "elm/time": "1.0.0", 24 | "elm/virtual-dom": "1.0.3", 25 | "rtfeldman/elm-hex": "1.0.0" 26 | } 27 | }, 28 | "test-dependencies": { 29 | "direct": {}, 30 | "indirect": {} 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /html/css/dark-theme.css: -------------------------------------------------------------------------------- 1 | 2 | /* TODO remove confirm-deletion-alert */ 3 | 4 | *::selection { 5 | background: teal; 6 | color: #fff; 7 | } 8 | 9 | 10 | 11 | /* FIXME will not work when removing parent classes! */ 12 | .property, .graph-node, .connector, #search input, #match-limit, #match-limit input, 13 | .property .chars.input,.property .int.input, #edit-example-container, 14 | .selected.graph-node .menu 15 | { 16 | transition: background-color 250ms, opacity 100ms; 17 | } 18 | 19 | .property .chars.input, .property .int.input { 20 | transition: border-color 150ms; 21 | } 22 | 23 | .property, nav a, #example-text, .graph-node .menu { 24 | transition: color 150ms; 25 | } 26 | 27 | .graph-node .menu, .selected.graph-node .menu { 28 | transition: height 80ms, top 80ms; 29 | } 30 | 31 | 32 | code, #search #results code { 33 | font-family: 'Ubuntu Mono', source-code-pro, Consolas, monospace; 34 | } 35 | 36 | textarea:focus, input:focus { 37 | outline: none; 38 | } 39 | 40 | body { 41 | caret-color: teal; 42 | background-color: #222; 43 | color: #ddd; 44 | } 45 | 46 | header > h1 > a { 47 | color: inherit; 48 | text-decoration: inherit; 49 | } 50 | 51 | header > a { 52 | color: #666; 53 | text-decoration: none; 54 | } 55 | header > a:hover { 56 | color: #888; 57 | } 58 | nav { 59 | background-color: #333; 60 | } 61 | 62 | #search, #search input { 63 | text-align: center; 64 | } 65 | #search #results { 66 | background-color: #444; 67 | color: #bbb; 68 | } 69 | #search #results > *:hover { 70 | background-color: #555; 71 | color: #ddd; 72 | } 73 | #search #results .description { 74 | display: none; 75 | } 76 | 77 | #search #results > *:hover .description, 78 | #search #results:not(:hover) > *:first-child .description 79 | { 80 | display: block; 81 | } 82 | 83 | #search input, #match-limit input { 84 | background: #444; 85 | border: 2px solid #444; 86 | color: #fff; 87 | } 88 | #search input:focus, #match-limit input:focus { 89 | background: transparent; 90 | border: 2px solid #444; 91 | } 92 | #search #results code { 93 | color: #0bb; 94 | } 95 | #search #results .description { 96 | opacity: 0.3; 97 | } 98 | 99 | #expression-result { 100 | background: #1b1b1b; 101 | color: #ddd; 102 | } 103 | #expression-result #declaration { 104 | color: #666; 105 | font: inherit; 106 | } 107 | #expression-result #lock { 108 | fill: #666; 109 | stroke: #666; 110 | } 111 | #expression-result #lock:hover { 112 | background-color: #242424; 113 | } 114 | #expression-result #lock.checked { 115 | background-color: #111; 116 | fill: #ddd; 117 | stroke: #ddd; 118 | } 119 | 120 | #match-limit { 121 | color: #999; 122 | } 123 | 124 | #edit-example { 125 | color: #999; 126 | } 127 | #edit-example:hover { 128 | background-color: #393939; 129 | } 130 | .editing-example-text #edit-example { 131 | background-color: #222; 132 | color: #ddd; 133 | } 134 | 135 | #history .button { 136 | opacity: 0.7; 137 | } 138 | #history .button:hover { 139 | opacity: 0.9; 140 | } 141 | #history .disabled.button { 142 | opacity: 0.2; 143 | } 144 | 145 | #example-text { 146 | /*animation: enterEditableText 0.2s forwards reverse; FIXME would flash on page load */ 147 | color: #444; 148 | opacity: 0.7; /* avoid too bright text highlighting */ 149 | } 150 | #example-text .match { 151 | color: #777; 152 | } 153 | .editing-example-text #example-text { 154 | animation: enterEditableText 0.2s forwards; 155 | 156 | border: none; /* reset textarea styling */ 157 | background: transparent; 158 | } 159 | 160 | /* on-spawn animation is started when example text area is swapped with div */ 161 | @keyframes enterEditableText { 162 | from { color: #444; } 163 | to { color: #888; } 164 | } 165 | 166 | .alert { 167 | background-color: #00000066; 168 | } 169 | .alert .dialog-box p { 170 | background-color: #eee; 171 | font-weight: bold; 172 | border-top: 5px solid #804; 173 | color: #444; 174 | } 175 | .alert .button { 176 | background-color: #fff; 177 | color: #444; 178 | } 179 | .alert .confirm { 180 | background-color: #804; 181 | color: #eee; 182 | } 183 | .notification { 184 | background: #111; 185 | color: #da0; 186 | } 187 | .notification div { 188 | color: #dddddd77; 189 | } 190 | .show.notification { 191 | animation: wobble 0.2s forwards; 192 | } 193 | @keyframes wobble { 194 | 0% { transform: translate(0px, 0px) } 195 | 33% { transform: translate(-4px, 0px) } 196 | 66% { transform: translate(4px, 0px) } 197 | 100% { transform: translate(0px, 0px) } 198 | } 199 | 200 | 201 | 202 | .connection { 203 | stroke: teal; 204 | } 205 | 206 | .graph-node .properties { 207 | background-color: #eee; 208 | box-shadow: 2px 2px 0 0 #00000044; 209 | } 210 | .graph-node { 211 | cursor: pointer; 212 | } 213 | 214 | .output.graph-node .properties { 215 | color: #088; 216 | } 217 | 218 | .move-dragging * { 219 | cursor: grabbing; 220 | } 221 | .connect-dragging * { 222 | cursor: pointer; 223 | } 224 | 225 | .graph-node .menu { 226 | background-color: #00000088; 227 | } 228 | .graph-node .menu .button { 229 | opacity: 0.6; 230 | } 231 | .graph-node .menu .button:hover { 232 | background-color: #111; 233 | color: #ddd; 234 | opacity: 1; 235 | } 236 | .graph-node .menu .delete.button:hover { 237 | background-color: #804; 238 | } 239 | 240 | .property:hover { 241 | background-color: #fff; 242 | } 243 | .connect-dragging .property:hover { 244 | background-color: #eeeeee22; 245 | } 246 | 247 | .property .chars.input, .property .int.input, .property .char.input, .property .regex-preview { 248 | border: 1px solid transparent; 249 | background-color: #22222222; 250 | font-family: 'Ubuntu Mono', source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; 251 | } 252 | .property .chars.input:focus, .property .int.input:focus, .property .char.input:focus { 253 | border: 1px solid #333; 254 | background-color: transparent; 255 | } 256 | 257 | .property .regex-preview { 258 | opacity: 0.5; 259 | } 260 | 261 | .connector { 262 | background-color: teal; 263 | } 264 | .inactive.connector { 265 | opacity: 0; 266 | } 267 | 268 | .graph-node .properties { 269 | color: #333; 270 | } 271 | .graph-node input { 272 | color: inherit; 273 | } 274 | 275 | .connect-dragging .graph-node:not(.connecting) .property.connectable-input { 276 | background-color: #ddd; 277 | } 278 | .connect-dragging .graph-node .properties { 279 | background-color: #444; 280 | } 281 | 282 | .connect-dragging .property:not(.connectable-input) { 283 | opacity: 0.25; 284 | color: #fff; 285 | } 286 | .connect-dragging .property:first-child:not(.connectable-input) { 287 | opacity: 0.6; 288 | } 289 | 290 | .connecting.graph-node .property:first-child { 291 | animation: flashToTealBackground 1s ease-out forwards; 292 | background-image: url("../img/energy-background.svg"); 293 | background-repeat: no-repeat; 294 | background-position: 0% 0%; 295 | background-size: 100% 100%; 296 | opacity: 1; 297 | color: #fff; 298 | } 299 | 300 | .connect-dragging .connector { 301 | background-color: #555; 302 | } 303 | .connect-dragging .connectable-input.property .connector { 304 | background-color: transparent; 305 | } 306 | .connect-dragging .graph-node:not(.connecting) .connectable-input.property .left.connector { 307 | background-color: teal; 308 | } 309 | 310 | .connecting.graph-node .property:first-child .connector { 311 | opacity: 0; 312 | } 313 | 314 | .connect-dragging .connection:not(.prototype) { 315 | stroke: #555; 316 | } 317 | .connect-dragging .prototype.connection { 318 | animation: fromSolidToDash 2s ease-out forwards; 319 | } 320 | :not(.connect-dragging) .connection:not(.prototype) { 321 | animation: fromDashToSolid 2s ease-in-out forwards; 322 | } 323 | 324 | .may-drag-connect .property:hover { 325 | background-color: teal; 326 | animation: flashToTealBackground 0.3s ease-out forwards; 327 | color: #ffffff77; 328 | } 329 | .may-drag-connect .property:hover .connector { 330 | background-color: #ffffff44; 331 | } 332 | 333 | 334 | 335 | 336 | @keyframes flashToTealBackground { 337 | from { 338 | background-color: #4FC195; 339 | } 340 | to { 341 | background-color: teal; 342 | } 343 | } 344 | 345 | /*@keyframes fromDashToSolid { 346 | 0% { 347 | stroke-dasharray: 9; 348 | stroke: #4FC195; 349 | } 350 | 99.99% { 351 | stroke-dasharray: 3; 352 | } 353 | 100% { 354 | stroke-dasharray: 0; 355 | stroke: teal; 356 | } 357 | }*/ 358 | @keyframes fromSolidToDash { 359 | 0% { 360 | /*stroke-dasharray: 0;*/ 361 | stroke: #4FC195; 362 | } 363 | /*0.001% { 364 | stroke-dasharray: 3; 365 | }*/ 366 | 100% { 367 | /*stroke-dasharray: 9;*/ 368 | stroke: teal; 369 | } 370 | } 371 | 372 | 373 | 374 | 375 | /* LOCK */ 376 | 377 | #lock #bracket { 378 | fill: none; 379 | stroke: inherit; 380 | stroke-width: 1; 381 | 382 | /*animation: closeBracket 0.6s forwards reverse; FIXME */ 383 | } 384 | 385 | #lock #body { 386 | fill: inherit; 387 | } 388 | 389 | .checked#lock #bracket { 390 | animation: closeBracket 0.4s forwards; 391 | } 392 | 393 | @keyframes closeBracket { 394 | 0% { transform: translate(0,0) } 395 | 40% { transform: translate(0,-0.5px) } 396 | 70% { transform: translate(0,2.5px) } 397 | 100% { transform: translate(0,2px) } 398 | } 399 | 400 | 401 | -------------------------------------------------------------------------------- /html/css/layout.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Ubuntu+Mono:700'); 2 | @import url('https://fonts.googleapis.com/css?family=Roboto+Condensed:400,700'); 3 | 4 | /* TODO remove confirm-deletion-alert */ 5 | 6 | * { 7 | margin: 0; 8 | padding: 0; 9 | overflow: hidden; 10 | 11 | font-family: 'Roboto Condensed', "Roboto", "Ubuntu", "Cantarell", sans-serif; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | body, #main, #overlay, #node-graph, #connection-graph, #example-text, #confirm-deletion-alert { 17 | position: fixed; 18 | top:0; left:0; 19 | width: 100vw; 20 | height: 100vh; 21 | overflow: hidden; 22 | } 23 | 24 | .button, #node-graph :not(input) { 25 | user-select: none; /* wont work for double click */ 26 | } 27 | .button { 28 | cursor: pointer; 29 | } 30 | 31 | .button::selection, #node-graph :not(input)::selection { 32 | color: inherit; 33 | background: transparent; /* user-select:none wont work for double click */ 34 | } 35 | 36 | 37 | .transform-wrapper { 38 | position: absolute; 39 | display: block; 40 | 41 | top: 0; left: 0; 42 | overflow: visible; 43 | } 44 | 45 | #example-text { 46 | font-size: 1.2em; 47 | 48 | overflow: hidden; 49 | box-sizing: border-box; 50 | padding: 70px 12px 12px 12px; 51 | text-align: justify; 52 | resize: none; 53 | white-space: pre-line; /* required for align:justify in a textarea */ 54 | 55 | user-select: auto; 56 | pointer-events: all; 57 | } 58 | 59 | .editing-example-text #example-text { 60 | } 61 | 62 | .editing-example-text #connection-graph, .editing-example-text #node-graph { 63 | display: none; 64 | } 65 | 66 | #overlay { 67 | pointer-events: none; 68 | } 69 | #overlay > * { 70 | pointer-events: all; 71 | } 72 | 73 | #overlay { 74 | display: flex; 75 | flex-direction: column; 76 | justify-content: space-between; 77 | align-items: stretch; 78 | } 79 | 80 | nav { 81 | display: flex; 82 | flex-direction: row; 83 | justify-content: space-between; 84 | align-items: stretch; 85 | } 86 | 87 | header { 88 | display: flex; 89 | flex-direction: row; 90 | justify-content: space-between; 91 | align-items: baseline; 92 | 93 | padding: 8px; 94 | height: 42px; 95 | } 96 | header > h1 { 97 | font-size: 24px; 98 | font-weight: normal; 99 | display: inline; 100 | margin-left: 8px; 101 | margin-right: 8px; 102 | } 103 | header > a { 104 | margin-left: 12px; 105 | font-size: 18px; 106 | } 107 | header > a, header > h1 { 108 | transform: translate(0px, -10px); 109 | } 110 | header img { 111 | display: inline-block; 112 | height: 100%; 113 | transform: scale(0.9) 114 | } 115 | 116 | #example-options { 117 | display: flex; 118 | flex-direction: row; 119 | justify-content: flex-end; 120 | align-items: center; 121 | } 122 | 123 | #match-limit { 124 | opacity: 0; 125 | pointer-events: none; 126 | } 127 | 128 | #match-limit input { 129 | width: 70px; 130 | padding: 4px; 131 | margin: 0 20px 0 20px; 132 | } 133 | 134 | .editing-example-text #match-limit { 135 | opacity: 1; 136 | pointer-events: all; 137 | } 138 | 139 | #edit-example { 140 | padding: 20px; 141 | } 142 | 143 | #search { 144 | position: fixed; 145 | top: 8px; 146 | left: 33vw; 147 | width: 33vw; 148 | 149 | display: flex; 150 | flex-direction: column; 151 | justify-content: flex-start; 152 | align-items: stretch; 153 | } 154 | #history { 155 | position: fixed; 156 | top: 8px; 157 | left: 68vw; 158 | height: 44px; 159 | 160 | display: flex; 161 | flex-direction: row; 162 | justify-content: flex-start; 163 | align-items: center; 164 | } 165 | #history .button { 166 | padding: 8px; 167 | } 168 | #history .disabled.button { 169 | pointer-events: none; 170 | } 171 | #history .button:hover { 172 | background-color: #444; 173 | } 174 | #history img { 175 | height: 20px; 176 | } 177 | #history #redo img { 178 | transform: scale(-1, 1); 179 | } 180 | 181 | #search input { 182 | font-size: 18px; 183 | padding: 8px; 184 | box-sizing: border-box; 185 | } 186 | 187 | #search #results { 188 | max-height: 80vh; 189 | overflow-x: hidden; 190 | overflow-y: auto; 191 | } 192 | 193 | #search #results > * { 194 | font-size: 18px; 195 | padding: 6px; 196 | } 197 | 198 | #expression-result { 199 | display: flex; 200 | flex-direction: row; 201 | align-items: stretch; 202 | max-height: 40vh; 203 | overflow-y: auto; 204 | 205 | font-size: 28px; 206 | } 207 | 208 | .no#expression-result { 209 | visibility: hidden; 210 | } 211 | 212 | #expression-result code { 213 | overflow-y: auto; 214 | text-align: center; 215 | flex-grow: 1; 216 | padding: 12px; 217 | } 218 | 219 | #expression-result #lock { 220 | height: 100%; 221 | } 222 | 223 | #lock svg { 224 | padding: 0 32px; 225 | height: 100%; 226 | transform: scale(0.5); 227 | } 228 | 229 | .alert { 230 | position: absolute; 231 | top:0; left:0; 232 | width: 100vw; height: 100vh; 233 | 234 | display: flex; 235 | visibility: hidden; 236 | } 237 | 238 | .show.alert { 239 | visibility: visible; 240 | } 241 | 242 | .dialog-box { 243 | margin: 25vh auto auto auto; 244 | font-size: 1.4em; 245 | 246 | display: flex; 247 | flex-direction: column; 248 | align-items: stretch; 249 | text-align: center; 250 | } 251 | .dialog-box p { 252 | padding: 18px; 253 | } 254 | .dialog-box .options { 255 | display: flex; 256 | flex-direction: row; 257 | align-items: stretch; 258 | } 259 | .dialog-box .button { 260 | padding: 12px; 261 | width: 50%; 262 | } 263 | .notification { 264 | position: absolute; 265 | bottom: 80px; 266 | right: 40px; 267 | visibility: hidden; 268 | padding: 12px; 269 | } 270 | .show.notification { 271 | visibility: visible; 272 | } 273 | 274 | .graph-node { 275 | position: absolute; 276 | top:0; left:0; 277 | font-size: 14px; 278 | 279 | overflow: visible; /* display the menu */ 280 | } 281 | 282 | .graph-node .properties { 283 | display: flex; 284 | flex-direction: column; 285 | align-items: stretch; 286 | align-content: stretch; 287 | } 288 | 289 | .graph-node .menu { 290 | position: absolute; 291 | right: 0; top: 0; 292 | height: 0; 293 | 294 | display: flex; 295 | flex-direction: row; 296 | } 297 | #main:not(.connect-dragging) .selected.graph-node .menu { 298 | top: -25px; 299 | height: 25px; 300 | } 301 | .graph-node .menu .button, .menu .button img { 302 | height: 100%; 303 | } 304 | .menu .button img { 305 | margin: 0; padding: 0; 306 | transform: scale(0.8); 307 | height: 100%; 308 | } 309 | 310 | .property { 311 | height: 25px; 312 | display: flex; 313 | flex-direction: row; 314 | align-items: center; 315 | align-content: stretch; 316 | justify-content: space-between; 317 | } 318 | 319 | /* align input items to the left by filling space with the name */ 320 | .property > .title { 321 | text-align: left; 322 | margin-right: 6px; 323 | flex-grow: 1; 324 | text-overflow: ellipsis; 325 | white-space: nowrap; 326 | } 327 | 328 | /*.property.main*/ .property:first-child > .title { 329 | text-align: center; 330 | font-weight: 700; 331 | } 332 | 333 | /* override priority, maximise text input size */ 334 | .property > .chars.input, .property > .int.input { 335 | flex-grow: 100; 336 | } 337 | 338 | /* override priority, maximise text input size */ 339 | .property > .chars.input, .property > .int.input, .property > .char.input { 340 | text-align: center; 341 | } 342 | 343 | .property .regex-preview { 344 | font-size: 0.7em; 345 | padding: 0 2px; 346 | text-overflow: ellipsis; 347 | white-space: nowrap; 348 | flex-shrink: 100; 349 | border-radius: 1px; 350 | } 351 | 352 | .property input { 353 | padding: 2px; 354 | font-size: 0.7em; 355 | width: 10px; 356 | } 357 | 358 | .connector { 359 | width: 4px; 360 | height: 100%; 361 | flex-shrink: 0; 362 | } 363 | 364 | .left.connector { 365 | margin-right: 6px; 366 | } 367 | 368 | .right.connector { 369 | margin-left: 6px; 370 | } 371 | 372 | .inactive.connector { 373 | opacity: 0; 374 | } 375 | 376 | .connect-dragging .property:not(.connectable-input) { 377 | pointer-events: none; 378 | } 379 | 380 | 381 | .connection { 382 | stroke-linecap: round; 383 | stroke-width: 3; 384 | fill: none; 385 | } 386 | -------------------------------------------------------------------------------- /html/img/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 14 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /html/img/bin.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 18 | 19 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /html/img/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 12 | 20 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /html/img/energy-background.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /html/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johannesvollmer/regex-nodes/61121c8a189edffda56f31b3a3de55e69a716253/html/img/favicon.png -------------------------------------------------------------------------------- /html/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /html/img/tidy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Regex Nodes 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 30 | 31 | -------------------------------------------------------------------------------- /modd.conf: -------------------------------------------------------------------------------- 1 | **/*.elm { 2 | prep: elm make src/Main.elm --optimize --output=html/built.js 3 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { }, 3 | 4 | "scripts": { 5 | "watch": "nodemon -x \"elm make src/Main.elm --optimize --output=html/built.js\"" 6 | }, 7 | 8 | "nodemonConfig": { 9 | "verbose": true, 10 | "ignore": [ 11 | "elm-stuff" 12 | ], 13 | "ext": "elm" 14 | } 15 | } -------------------------------------------------------------------------------- /readme/browser-stack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johannesvollmer/regex-nodes/61121c8a189edffda56f31b3a3de55e69a716253/readme/browser-stack.png -------------------------------------------------------------------------------- /src/AutoLayout.elm: -------------------------------------------------------------------------------- 1 | module AutoLayout exposing (..) 2 | 3 | import Array 4 | import Dict 5 | import IdMap 6 | import Model exposing (..) 7 | import Set 8 | import Vec2 exposing (Vec2) 9 | 10 | 11 | 12 | layout: Bool -> NodeId -> Nodes -> Nodes 13 | layout hard nodeId nodes = 14 | let 15 | -- build simulation from real node graph 16 | current = buildBlockGraph nodeId nodes 17 | 18 | -- do a manual base layout as a starting point, if desired 19 | baseBlocks = if hard 20 | then baseLayout nodeId current 21 | else current 22 | 23 | -- do an iterative physical layout 24 | smoothedBlocks = forceBasedLayout baseBlocks 25 | 26 | finalBlocks = smoothedBlocks 27 | 28 | -- move all nodes such that the main node does not move 29 | delta = Maybe.map2 (\original new -> Vec2.sub original.position new.position) 30 | (IdMap.get nodeId nodes) (Dict.get nodeId finalBlocks) 31 | |> Maybe.withDefault Vec2.zero 32 | 33 | -- transfer simulation to real node graph 34 | updateNode id nodeView = 35 | case Dict.get id finalBlocks of 36 | Nothing -> nodeView 37 | Just simulatedBlock -> 38 | { nodeView | position = Vec2.add delta simulatedBlock.position } 39 | 40 | in IdMap.updateAll updateNode nodes 41 | 42 | 43 | type alias NodeBlock = 44 | { position: Vec2 -- top left corner 45 | , size: Vec2 -- rectangle width x height 46 | , inputs: List { property: Int, connected: NodeId } 47 | } 48 | 49 | type alias NodeBlocks = Dict.Dict NodeId NodeBlock 50 | 51 | 52 | buildBlockGraph: NodeId -> Nodes -> NodeBlocks 53 | buildBlockGraph nodeId nodes = 54 | case IdMap.get nodeId nodes of 55 | Nothing -> Dict.empty 56 | Just nodeView -> 57 | let 58 | properties = nodeProperties nodeView.node 59 | 60 | size = Vec2 (nodeWidth nodeView.node) (List.length properties |> toFloat |> (*) propertyHeight) 61 | positionedBlock = NodeBlock nodeView.position size 62 | 63 | getInputs property = case property.contents of 64 | ConnectingProperties _ props _ -> List.map Just (Array.toList props) 65 | ConnectingProperty prop _ -> [ prop ] 66 | _ -> [ Nothing ] 67 | 68 | inputs = properties |> List.map getInputs |> flattenList 69 | indexedInputs = inputs |> List.indexedMap 70 | (\index input -> Maybe.map (\i -> { property = index, connected = i }) input) 71 | 72 | filteredInputs = List.filterMap identity indexedInputs 73 | filteredRawInputs = List.filterMap identity inputs 74 | 75 | blocksOfInput input blocks = Dict.union blocks (buildBlockGraph input nodes) 76 | inputBlocks = List.foldr blocksOfInput Dict.empty filteredRawInputs 77 | block = positionedBlock filteredInputs 78 | 79 | in Dict.insert nodeId block inputBlocks 80 | 81 | 82 | flattenList list = List.foldr (++) [] list 83 | 84 | -- TODO if a node has multiple outputs, it should be placed in the lowest layer 85 | -- TODO layout every node once, which also avoids stack overflow on circular node graphs 86 | 87 | baseLayout : NodeId -> NodeBlocks -> NodeBlocks 88 | baseLayout nodeId blocks = 89 | let 90 | totalHeight = treeHeight blocks nodeId 91 | block = Dict.get nodeId blocks 92 | { x, y } = block |> Maybe.map .position |> Maybe.withDefault Vec2.zero 93 | size = block |> Maybe.map .size |> Maybe.withDefault Vec2.zero 94 | 95 | in baseLayoutToHeight nodeId blocks totalHeight (x + size.x) (y - 0.5*totalHeight + 0.5*size.y) 96 | 97 | baseHorizontalPadding = 2 * propertyHeight 98 | layerHeightFactor = 1 99 | 100 | baseLayoutToHeight : NodeId -> NodeBlocks -> Float -> Float -> Float -> NodeBlocks 101 | baseLayoutToHeight nodeId blocks height rightX topY = 102 | case Dict.get nodeId blocks of 103 | Nothing -> blocks 104 | Just block -> 105 | let 106 | 107 | -- increase spacing where many children stack up to a great height 108 | childrenRightX = rightX - block.size.x - baseHorizontalPadding 109 | - layerHeightFactor * propertyHeight * toFloat (List.length block.inputs) 110 | 111 | layoutSubBlock input (y, subblocks) = 112 | let 113 | subHeight = treeHeight blocks input 114 | in (y + subHeight, baseLayoutToHeight input subblocks subHeight childrenRightX y) 115 | 116 | (_, newBlocks) = List.map .connected block.inputs 117 | |> deduplicateInOrder 118 | |> List.foldl layoutSubBlock (topY, blocks) 119 | 120 | newBlock = { block | position = Vec2 (rightX - block.size.x) (topY + 0.5 * height - 0.5 * block.size.y) } 121 | 122 | in 123 | Dict.insert nodeId newBlock newBlocks 124 | 125 | 126 | treeHeight: NodeBlocks -> NodeId -> Float 127 | treeHeight blocks nodeId = 128 | case Dict.get nodeId blocks of 129 | Just block -> max (blockSelfHeight block) (blockChildrenHeight blocks block) 130 | Nothing -> 0 131 | 132 | blockChildrenHeight blocks block = 133 | block.inputs 134 | |> List.map .connected 135 | |> deduplicateRandomOrder 136 | |> List.map (treeHeight blocks) 137 | |> List.foldr (+) 0 138 | 139 | blockSelfHeight block = 140 | block.size.y + 2 * propertyHeight 141 | 142 | 143 | 144 | deduplicateRandomOrder: List comparable -> List comparable 145 | deduplicateRandomOrder = Set.fromList >> Set.toList 146 | 147 | deduplicateInOrder: List comparable -> List comparable 148 | deduplicateInOrder list = 149 | List.foldr buildDedupSet ([], Set.empty) list |> Tuple.first 150 | 151 | buildDedupSet element (resultList, resultSet) = 152 | if Set.member element resultSet then (resultList, resultSet) 153 | else (element :: resultList, Set.insert element resultSet) 154 | 155 | 156 | 157 | 158 | -- ITERATIVE FORCE LAYOUT 159 | 160 | -- force proportions: 161 | uncollide = 1 162 | horizontalUntwist = 0.6 163 | horizontalGroup = 0.001 164 | verticalConvergence = 0.0001 165 | groupAll = 0.000000001 166 | 167 | -- minimal distances: 168 | horizontalPadding = 1 * propertyHeight 169 | collisionPadding = 0.9 * propertyHeight 170 | keepDistanceToLargeLayers = 2 * propertyHeight 171 | 172 | -- automatic calculation of number of iterations 173 | forceBasedLayout blocks = 174 | let 175 | atLeast = max 176 | nodes = Dict.size blocks |> toFloat 177 | complexity = nodes * nodes 178 | budged = 2048 * 32 179 | 180 | desiredIterations = budged / complexity 181 | iterations = floor desiredIterations |> atLeast 1 182 | 183 | in repeat iterations iterateLayout blocks 184 | 185 | 186 | iterateLayout blocks = blocks |> Dict.map (iterateBlock blocks) 187 | 188 | 189 | hasInput input block = 190 | List.map .connected block.inputs |> List.member input 191 | 192 | 193 | -- simulating a single block 194 | iterateBlock: NodeBlocks -> NodeId -> NodeBlock -> NodeBlock 195 | iterateBlock blocks id block = 196 | let 197 | accumulateForceBetweenNodes otherId otherBlock force = 198 | if id == otherId then force else 199 | let 200 | center = Vec2.ray 0.5 block.size block.position 201 | otherCenter = Vec2.ray 0.5 otherBlock.size otherBlock.position 202 | minDistance = 0.6 * (Vec2.length block.size) + 0.6 * (Vec2.length otherBlock.size) + collisionPadding 203 | difference = Vec2.sub center otherCenter 204 | distance = Vec2.length difference 205 | 206 | distanceForce = 207 | if distance < minDistance then Vec2.scale (0.5 * uncollide / distance) difference -- push apart if colliding (normalizing the difference) (0.5 because every node only pushes itself) 208 | else Vec2.scale -(groupAll * distance) difference -- pull together slightly 209 | 210 | otherIsInput = hasInput otherId block 211 | otherIsOutput = hasInput id otherBlock 212 | 213 | smoothnessBetween leftBlock rightBlock = 214 | rightBlock.position.x - (leftBlock.position.x + leftBlock.size.x) 215 | - horizontalPadding -- - keepDistanceToLargeLayers * abs difference.y 216 | - keepDistanceToLargeLayers * (toFloat <| max (List.length leftBlock.inputs) (List.length rightBlock.inputs)) 217 | 218 | smoothen left right = 219 | let smoothness = smoothnessBetween left right 220 | in (if smoothness < 0 then horizontalUntwist else horizontalGroup) * -smoothness 221 | 222 | 223 | horizontalConnectionForce = 224 | if otherIsInput then smoothen otherBlock block 225 | -- let smoothness = smoothnessBetween otherBlock block 226 | -- in (if smoothness < 0 then horizontalUntwist else horizontalGroup) * -smoothness 227 | 228 | else if otherIsOutput then -(smoothen block otherBlock) 229 | -- let smoothness = smoothnessBetween block otherBlock 230 | -- in -((if smoothness < 0 then horizontalUntwist else horizontalGroup) * -smoothness) 231 | else 0 232 | 233 | verticalConnectionForce = 234 | if otherIsInput || otherIsOutput then 235 | verticalConvergence * -difference.y 236 | else 0 237 | 238 | 239 | connectionForce = Vec2 (0.5 * horizontalConnectionForce) (0.5 * verticalConnectionForce) -- * 0.5 because every node processes every other 240 | in 241 | force |> Vec2.add distanceForce |> Vec2.add connectionForce 242 | 243 | collisionForce = blocks |> Dict.foldr accumulateForceBetweenNodes Vec2.zero 244 | totalForce = collisionForce 245 | in 246 | { block | position = Vec2.add block.position totalForce } 247 | 248 | repeat: Int -> (a -> a) -> a -> a 249 | repeat count action value = 250 | if count <= 0 then value 251 | else repeat (count - 1) action (action value) -- tail call 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | -------------------------------------------------------------------------------- /src/Build.elm: -------------------------------------------------------------------------------- 1 | module Build exposing (..) 2 | 3 | import Array 4 | import IdMap 5 | import Regex 6 | 7 | import Model exposing (..) 8 | 9 | 10 | 11 | cycles = "Nodes have cycles" -- TODO use enum 12 | 13 | {- 14 | The "cycle detection" works by having a cost attached to each node 15 | while generating the regular expression, and cycles being infinite 16 | will always exceed the maximum cost. 17 | 18 | This means that overly complex (non-cyclic) nodes also will be rejected, 19 | and the message text currently incorrectly says it's because of cycles in the node graph. 20 | This will be improved later but it has a low priority. 21 | -} 22 | 23 | 24 | 25 | escapeCharset = escapeChars "[^-.\\]" 26 | escapeLiteral = escapeChars "[]{}()|^.-+*?!$/\\" 27 | andMinimal min expression = if min 28 | then expression ++ "?" else expression 29 | 30 | 31 | set options = if not (List.isEmpty options) 32 | then String.join "|" options 33 | else "(nothing)" 34 | 35 | capture child = "(" ++ child ++ ")" 36 | 37 | sequence members = if not (List.isEmpty members) 38 | then String.concat members 39 | else "(nothing)" 40 | 41 | optional min expression = expression ++ "?" |> andMinimal min 42 | atLeastOne min expression = expression ++ "+" |> andMinimal min 43 | anyRepetition min expression = expression ++ "*" |> andMinimal min 44 | 45 | exactRepetition count expression = expression ++ "{" ++ String.fromInt count ++ "}" 46 | minimumRepetition min minimum expression = expression ++ "{" ++ String.fromInt minimum ++ ",}" |> andMinimal min 47 | maximumRepetition min maximum expression = expression ++ "{0," ++ String.fromInt maximum ++ "}" |> andMinimal min 48 | rangedRepetition min minimum maximum expression = expression 49 | ++ "{" ++ String.fromInt minimum 50 | ++ "," ++ String.fromInt maximum 51 | ++ "}" |> andMinimal min 52 | 53 | ifFollowedBy successor expression = expression ++ "(?=" ++ successor ++ ")" 54 | ifNotFollowedBy successor expression = expression ++ "(?!" ++ successor ++ ")" 55 | 56 | charset chars = "[" ++ escapeCharset chars ++ "]" 57 | notInCharset chars = "[^" ++ escapeCharset chars ++ "]" 58 | charRange start end = "[" ++ escapeCharset (String.fromChar start) ++ "-" ++ escapeCharset (String.fromChar end) ++ "]" 59 | notInCharRange start end = "[^" ++ escapeCharset (String.fromChar start) ++ "-" ++ escapeCharset (String.fromChar end) ++ "]" 60 | literal chars = escapeLiteral chars 61 | 62 | 63 | 64 | 65 | 66 | buildNodeExpression : Int -> Nodes -> Node -> BuildResult (Int, String) 67 | buildNodeExpression cost nodes node = 68 | let 69 | ownPrecedence = precedence node 70 | build childParens depth child = buildExpression childParens depth nodes ownPrecedence child 71 | 72 | buildSingleChild childParens map child = build childParens cost child |> Result.map (Tuple.mapSecond map) 73 | buildTwoChildren map childParens1 child1 childParens2 child2 = 74 | let 75 | first = build childParens1 cost child1 76 | buildSecond (firstCost, _) = build childParens2 firstCost child2 77 | merge (_, firstChild) (totalCost, secondChild) = (totalCost, map firstChild secondChild) 78 | 79 | in Result.map2 merge first (Result.andThen buildSecond first) 80 | 81 | buildMember : Bool -> NodeId -> Result String (Int, List String) -> Result String (Int, List String) 82 | buildMember childParens element lastResult = 83 | lastResult |> Result.andThen (\(currentCost, builtMembers) -> 84 | (build childParens currentCost (Just element)) |> Result.map (Tuple.mapSecond (\e -> e :: builtMembers)) 85 | ) 86 | 87 | buildMembers childParens join members = members |> Array.toList 88 | |> List.foldr (buildMember childParens) (Ok (cost, [])) 89 | |> Result.map (Tuple.mapSecond join) 90 | 91 | string = case node of 92 | SymbolNode symbol -> Ok (cost, buildSymbol symbol) 93 | CharSetNode chars -> Ok (cost, charset chars) 94 | NotInCharSetNode chars -> Ok (cost, notInCharset chars) 95 | CharRangeNode start end -> Ok (cost, simplifyInCharRange start end) 96 | NotInCharRangeNode start end -> Ok (cost, simplifyNotInCharRange start end) 97 | LiteralNode chars -> Ok (cost, literal chars) 98 | 99 | SequenceNode members -> buildMembers True sequence members 100 | SetNode options -> buildMembers True set options 101 | CaptureNode child -> buildSingleChild False capture child 102 | 103 | FlagsNode { expression } -> build False cost expression -- we use flags directly at topmost level 104 | 105 | IfNotFollowedByNode { expression, successor } -> buildTwoChildren ifNotFollowedBy False successor True expression 106 | IfFollowedByNode { expression, successor } -> buildTwoChildren ifFollowedBy False successor True expression 107 | 108 | OptionalNode { expression, minimal } -> buildSingleChild True (optional minimal) expression 109 | AtLeastOneNode { expression, minimal } -> buildSingleChild True (atLeastOne minimal) expression 110 | AnyRepetitionNode { expression, minimal } -> buildSingleChild True (anyRepetition minimal) expression 111 | ExactRepetitionNode { expression, count } -> buildSingleChild True (exactRepetition count) expression 112 | MinimumRepetitionNode { expression, count, minimal } -> buildSingleChild True (minimumRepetition minimal count) expression 113 | MaximumRepetitionNode { expression, count, minimal } -> buildSingleChild True (maximumRepetition minimal count) expression 114 | 115 | RangedRepetitionNode { expression, minimum, maximum, minimal } -> 116 | expression |> simplifyRangedRepetition minimal minimum maximum (buildSingleChild True) 117 | -- buildSingleChild True (rangedRepetition minimal minimum maximum) expression 118 | 119 | 120 | in string 121 | 122 | 123 | -- TODO this is not parentheses aware 124 | simplifyRangedRepetition minimal minimum maximum buildSingleChild = 125 | if minimum == maximum then buildSingleChild (exactRepetition minimum) 126 | else buildSingleChild (rangedRepetition minimal minimum maximum) 127 | 128 | -- TODO not in range 129 | simplifyInCharRange start end = 130 | if start == '0' && end == '9' 131 | then buildSymbol DigitChar 132 | else charRange start end 133 | 134 | -- TODO not in range 135 | simplifyNotInCharRange start end = 136 | if start == '0' && end == '9' 137 | then buildSymbol NonDigitChar 138 | else notInCharRange start end 139 | 140 | 141 | {-| Dereferences a node and returns `buildExpression` of it, handling corner cases -} 142 | buildExpression : Bool -> Int -> Nodes -> Int -> Maybe NodeId -> BuildResult (Int, String) 143 | buildExpression childMayNeedParens cost nodes ownPrecedence nodeId = 144 | if cost < 0 then Err cycles 145 | else case nodeId of 146 | Nothing -> Ok (cost, "(?:nothing)") 147 | 148 | Just id -> 149 | let 150 | nodeResult = IdMap.get id nodes 151 | |> okOrErr "Internal Error: Invalid Node Id" 152 | 153 | parens node = if childMayNeedParens 154 | then parenthesesForPrecedence ownPrecedence (precedence node) 155 | else identity 156 | 157 | build: Node -> BuildResult (Int, String) 158 | build node = buildNodeExpression (cost - 1) nodes node |> Result.map (Tuple.mapSecond (parens node)) 159 | built = nodeResult |> Result.andThen (.node >> build) 160 | 161 | in built 162 | 163 | 164 | 165 | buildRegex : Nodes -> NodeId -> BuildResult (RegexBuild) 166 | buildRegex nodes id = 167 | let 168 | maxCost = (IdMap.size nodes) * 6 169 | expression = buildExpression False maxCost nodes 0 (Just id) 170 | nodeView = IdMap.get id nodes 171 | options = case nodeView |> Maybe.map .node of 172 | Just (FlagsNode { flags }) -> flags 173 | _ -> defaultFlags 174 | 175 | in expression |> Result.map (\(_, string) -> RegexBuild string options) 176 | 177 | 178 | constructRegexLiteral : RegexBuild -> String 179 | constructRegexLiteral regex = 180 | "/" ++ regex.expression ++ "/" 181 | ++ (if regex.flags.multiple then "g" else "") 182 | ++ (if regex.flags.caseInsensitive then "i" else "") 183 | ++ (if regex.flags.multiline then "m" else "") 184 | 185 | 186 | compileRegex : RegexBuild -> Regex.Regex 187 | compileRegex build = 188 | let options = { caseInsensitive = build.flags.caseInsensitive, multiline = build.flags.multiline } 189 | in Regex.fromStringWith options build.expression |> Maybe.withDefault Regex.never 190 | 191 | 192 | parenthesesForPrecedence ownPrecedence childPrecedence child = 193 | if ownPrecedence > childPrecedence && childPrecedence < 5 -- atomic children don't need any parentheses 194 | then "(?:" ++ child ++ ")" else child 195 | 196 | precedence : Node -> Int 197 | precedence node = case node of 198 | FlagsNode _ -> 0 199 | 200 | SetNode _ -> 1 201 | 202 | LiteralNode text -> 203 | if String.length text == 1 204 | then 5 205 | else 2 206 | 207 | SequenceNode _ -> 2 -- TODO collapse if only one member 208 | 209 | -- IfAtEndNode _ -> 3 210 | -- IfAtStartNode _ -> 3 can if-at-start be a symbol? 211 | IfNotFollowedByNode _ -> 3 212 | IfFollowedByNode _ -> 3 213 | 214 | OptionalNode _ -> 4 -- at least one ... 215 | AtLeastOneNode _ -> 4 216 | MaximumRepetitionNode _ -> 4 217 | MinimumRepetitionNode _ -> 4 218 | ExactRepetitionNode _ -> 4 219 | RangedRepetitionNode _ -> 4 220 | AnyRepetitionNode _ -> 4 221 | 222 | CharSetNode _ -> 5 223 | NotInCharSetNode _ -> 5 224 | CharRangeNode _ _ -> 5 225 | NotInCharRangeNode _ _ -> 5 226 | CaptureNode _ -> 5 -- TODO will produce unnecessary parens if 227 | SymbolNode _ -> 5 228 | 229 | 230 | buildSymbol symbol = case symbol of 231 | WhitespaceChar -> "\\s" 232 | NonWhitespaceChar -> "\\S" 233 | DigitChar -> "\\d" 234 | NonDigitChar -> "\\D" 235 | WordChar -> "\\w" 236 | NonWordChar -> "\\W" 237 | WordBoundary -> "\\b" 238 | NonWordBoundary -> "\\B" 239 | LinebreakChar -> "\\n" 240 | NonLinebreakChar -> "." 241 | TabChar -> "\\t" 242 | Never -> "(?!)" 243 | Always -> "(.|\\n)" 244 | Start -> "^" 245 | End -> "$" 246 | 247 | 248 | escapeChars pattern chars = chars |> String.toList 249 | |> List.concatMap (escapeChar (String.toList pattern)) 250 | |> String.fromList 251 | 252 | escapeChar pattern char = 253 | if List.member char pattern 254 | then [ '\\', char ] 255 | else [ char ] 256 | 257 | okOrErr : e -> Maybe k -> Result e k 258 | okOrErr error maybe = case maybe of 259 | Just value -> Ok value 260 | Nothing -> Err error -------------------------------------------------------------------------------- /src/IdMap.elm: -------------------------------------------------------------------------------- 1 | module IdMap exposing (..) 2 | import Dict exposing (Dict) 3 | 4 | type alias Id = Int 5 | 6 | type alias IdMap v = 7 | { dict : Dict Id v 8 | , nextId : Id 9 | } 10 | 11 | 12 | empty = IdMap Dict.empty 0 13 | 14 | isEmpty : IdMap v -> Bool 15 | isEmpty idMap = idMap.dict |> Dict.isEmpty 16 | 17 | insertAnonymous : v -> IdMap v -> IdMap v 18 | insertAnonymous value idMap = 19 | { dict = idMap.dict |> Dict.insert idMap.nextId value 20 | , nextId = idMap.nextId + 1 21 | } 22 | 23 | insert : v -> IdMap v -> (Id, IdMap v) 24 | insert value idMap = 25 | ( idMap.nextId 26 | , insertAnonymous value idMap 27 | ) 28 | 29 | remove : Id -> IdMap v -> IdMap v 30 | remove id idMap = { idMap | dict = idMap.dict |> Dict.remove id } 31 | 32 | updateAllValues : (v -> v) -> IdMap v -> IdMap v 33 | updateAllValues mapper idMap = updateAll (\_ value -> mapper value) idMap 34 | 35 | updateAll : (Id -> v -> v) -> IdMap v -> IdMap v 36 | updateAll mapper idMap = { idMap | dict = idMap.dict |> Dict.map (\id v -> mapper id v) } 37 | 38 | get : Id -> IdMap v -> Maybe v 39 | get id idMap = idMap.dict |> Dict.get id 40 | 41 | update : Id -> (v -> v) -> IdMap v -> IdMap v 42 | update id mapper idMap = 43 | let updateDictValue oldValue = oldValue |> Maybe.map mapper 44 | in { idMap | dict = idMap.dict |> Dict.update id updateDictValue } 45 | 46 | 47 | type alias Insert v = IdMap v -> (Id, IdMap v) 48 | insertListWith : List (Insert v) -> IdMap v -> (List Id, IdMap v) 49 | insertListWith values idMap = 50 | let insertWithInserter inserter (ids, result) = inserter result |> Tuple.mapFirst (\id -> id :: ids) 51 | in List.foldr insertWithInserter ([], idMap) values 52 | 53 | 54 | toList : IdMap v -> List (Id, v) 55 | toList idMap = idMap.dict |> Dict.toList 56 | 57 | 58 | size = Dict.size << .dict 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/LinearDict.elm: -------------------------------------------------------------------------------- 1 | module LinearDict exposing (..) 2 | 3 | {- 4 | This is just like a Dict, 5 | but allows for non-comparable keys 6 | -} 7 | 8 | 9 | type alias LinearDict k v = List (k, v) 10 | 11 | empty: LinearDict k v 12 | empty = [] 13 | 14 | member: k -> LinearDict k v -> Bool 15 | member key dict = 16 | get key dict /= Nothing 17 | 18 | get : k -> LinearDict k v -> Maybe v 19 | get key dict = dict 20 | |> List.filter (Tuple.first >> (==) key) 21 | |> List.head |> Maybe.map Tuple.second 22 | 23 | 24 | insert: k -> v -> LinearDict k v -> LinearDict k v 25 | insert key value dict = -- TODO can be done in a recursive function with only one iteration instead of 2 26 | if member key dict 27 | then dict |> List.map (replaceValueForKey key value) 28 | else (key, value) :: dict 29 | 30 | 31 | replaceValueForKey: k -> v -> (k, v) -> (k, v) 32 | replaceValueForKey key newValue (currentKey, currentValue) = 33 | if key == currentKey then (currentKey, newValue) 34 | else (currentKey, currentValue) 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (..) 2 | 3 | import Browser 4 | import Build 5 | import Model 6 | import Update 7 | import Url 8 | import Base64 9 | import View 10 | 11 | port url : String -> Cmd msg 12 | 13 | 14 | main = Browser.element 15 | { init = \flags -> (init flags, Cmd.none) 16 | , update = \message model -> update message model 17 | , subscriptions = always Sub.none 18 | , view = View.view 19 | } 20 | 21 | 22 | init rawUrl = 23 | let 24 | expression = case String.split "?expression=" rawUrl of 25 | _ :: [ query ] -> Just query 26 | _ -> Nothing 27 | 28 | escapedExpression = expression |> Maybe.andThen Url.percentDecode 29 | -- expression is base64 encoded because firefox will change backslashes to regular slashes 30 | |> Maybe.andThen (Base64.decode >> Result.toMaybe) 31 | |> Maybe.withDefault "/\\s(?:the|for)/g" 32 | 33 | in 34 | Model.initialValue |> Update.update -- cannot be done in Model due to circular imports 35 | (Update.SearchMessage <| Update.FinishSearch <| Update.ParseRegex escapedExpression) 36 | 37 | update message model = 38 | let 39 | newModel = Update.update message model 40 | 41 | -- expression is base64 encoded because firefox will change backslashes to regular slashes 42 | encode = Base64.encode >> Url.percentEncode 43 | 44 | regex = newModel.history.present.cachedRegex 45 | |> Maybe.map (Result.map (Build.constructRegexLiteral >> encode)) 46 | 47 | in case regex of 48 | Just (Ok expression) -> (newModel, url ("?expression=" ++ expression)) 49 | _ -> (newModel, url "?") -------------------------------------------------------------------------------- /src/Model.elm: -------------------------------------------------------------------------------- 1 | module Model exposing (..) 2 | 3 | import Array exposing (Array) 4 | import Vec2 exposing (Vec2) 5 | import IdMap exposing (IdMap) 6 | 7 | -- MODEL 8 | 9 | 10 | -- TODO improve structure 11 | 12 | type alias Model = 13 | { history: History 14 | 15 | , view: View 16 | , search: Maybe String 17 | , dragMode: Maybe DragMode 18 | } 19 | 20 | 21 | type alias History = 22 | { past: List CoreModel 23 | , present: CoreModel 24 | , future: List CoreModel 25 | } 26 | 27 | 28 | type alias CoreModel = 29 | { nodes: Nodes 30 | , outputNode: OutputNode 31 | , selectedNode: Maybe NodeId 32 | 33 | , exampleText: ExampleText 34 | , cachedRegex: Maybe (BuildResult RegexBuild) 35 | 36 | , cyclesError: Bool 37 | } 38 | 39 | 40 | initialValue = 41 | { history = { past = [], present = initialHistoryValue, future = [] } 42 | , dragMode = Nothing 43 | , search = Nothing 44 | , view = View 0 Vec2.zero 45 | } 46 | 47 | 48 | initialHistoryValue : CoreModel 49 | initialHistoryValue = 50 | { nodes = IdMap.empty 51 | , outputNode = { id = Nothing, locked = False } 52 | , selectedNode = Nothing 53 | , cachedRegex = Nothing 54 | , cyclesError = False 55 | , exampleText = 56 | { contents = String.repeat 12 "Leverage agile frameworks to provide a robust synopsis for high level overviews. Iterative approaches to corporate strategy foster collaborative thinking to further the overall value proposition. With markdown, you can write inline html: ` and scripts `. But what would that feel like? Organically grow the holistic world view of disruptive innovation via workplace diversity and empowerment! Take your laptop 🖥. Also, have a look at these japanese symbols: シンボル. Bring to the table win-win survival strategies to ensure proactive domination. At the end of the day, going forward, a new normal that has evolved from generation X is on the runway heading towards 4 streamlined cloud solution. Consider mailing to what_is_this_4@i-dont-do-mail.org User generated content in real-time will have 4-128 touchpoints for offshoring. Capitalize on low hanging fruit to identify a ballpark value added activity to beta test. Override the digital divide with additional clickthroughs from DevOps. Nanotechnology immersion along the information highway will close the loop on focusing solely on the bottom line." 57 | , maxMatches = 4000 58 | , isEditing = False 59 | , cachedMatches = Nothing 60 | } 61 | } 62 | 63 | type alias NodeId = IdMap.Id 64 | type alias Nodes = IdMap NodeView 65 | 66 | -- TODO 67 | type alias Notifications = 68 | { cyclesError: Bool 69 | , parseRegexError: Bool 70 | , deletedNode: Bool -- TODO undo on click 71 | } 72 | 73 | type alias BuildResult a = Result String a 74 | 75 | type alias RegexBuild = 76 | { expression: String 77 | , flags: RegexFlags 78 | } 79 | 80 | type alias OutputNode = 81 | { id: Maybe NodeId 82 | , locked: Bool 83 | } 84 | 85 | type alias ExampleText = 86 | { isEditing: Bool 87 | , contents: String 88 | , maxMatches: Int 89 | , cachedMatches: Maybe (List (String, String)) 90 | } 91 | 92 | type alias View = 93 | { magnification : Float 94 | , offset : Vec2 95 | } 96 | 97 | type DragMode 98 | = MoveNodeDrag { node: NodeId, mouse: Vec2 } 99 | | MoveViewDrag { mouse: Vec2 } 100 | 101 | | PrepareEditingConnection { node: NodeId, mouse: Vec2 } 102 | | CreateConnection { supplier: NodeId, openEnd: Vec2 } 103 | | RetainPrototypedConnection { node: NodeId, previousNodeValue: Maybe Node, mouse: Vec2 } 104 | 105 | 106 | type alias NodeView = 107 | { position : Vec2 108 | , node : Node 109 | } 110 | 111 | type Node 112 | = SymbolNode Symbol 113 | 114 | | CharSetNode String 115 | | NotInCharSetNode String 116 | | LiteralNode String 117 | | CharRangeNode Char Char 118 | | NotInCharRangeNode Char Char 119 | 120 | | SetNode (Array NodeId) 121 | | SequenceNode (Array NodeId) 122 | 123 | | CaptureNode (Maybe NodeId) 124 | 125 | | IfFollowedByNode { expression : Maybe NodeId, successor : Maybe NodeId } 126 | | IfNotFollowedByNode { expression : Maybe NodeId, successor : Maybe NodeId } 127 | 128 | | OptionalNode { expression: Maybe NodeId, minimal: Bool } 129 | | AtLeastOneNode { expression: Maybe NodeId, minimal: Bool } 130 | | AnyRepetitionNode { expression: Maybe NodeId, minimal: Bool } 131 | | RangedRepetitionNode { expression : Maybe NodeId, minimum: Int, maximum: Int, minimal: Bool } 132 | | MinimumRepetitionNode { expression : Maybe NodeId, count: Int, minimal: Bool } 133 | | MaximumRepetitionNode { expression : Maybe NodeId, count: Int, minimal: Bool } 134 | | ExactRepetitionNode { expression : Maybe NodeId, count : Int } 135 | 136 | | FlagsNode { expression : Maybe NodeId, flags : RegexFlags } 137 | 138 | -- | Pattern { expression: Maybe NodeId, name: String } 139 | -- | SubResultNode { expression: Maybe NodeId } 140 | 141 | 142 | type Pattern 143 | = MinimalAlphabetLowercase 144 | | MinimalAlphabetUppercase 145 | | DecimalNumber 146 | | Integer 147 | | Hex 148 | | ScientificNumber 149 | | Punctuation 150 | 151 | {-| Any group of chars that can be represented by a single regex character, for example `\n` for linebreaks -} 152 | type Symbol 153 | = WhitespaceChar 154 | | NonWhitespaceChar 155 | | DigitChar 156 | | NonDigitChar 157 | | WordChar 158 | | NonWordChar 159 | | WordBoundary 160 | | NonWordBoundary 161 | | LinebreakChar 162 | | NonLinebreakChar 163 | | TabChar 164 | | Never 165 | | Always 166 | | Start 167 | | End 168 | 169 | type alias Prototype = 170 | { name : String 171 | , node : Node 172 | , description: String 173 | } 174 | 175 | 176 | 177 | prototypes : List Prototype 178 | prototypes = 179 | [ symbolProto .whitespace WhitespaceChar 180 | , symbolProto .nonWhitespace NonWhitespaceChar 181 | , symbolProto .digit DigitChar 182 | , symbolProto .nonDigit NonDigitChar 183 | 184 | , typeProto .charset (CharSetNode "AEIOU") 185 | , typeProto .set (SetNode (Array.fromList [])) 186 | 187 | , typeProto .literal (LiteralNode "the") 188 | , typeProto .sequence (SequenceNode (Array.fromList [])) 189 | 190 | , typeProto .charRange (CharRangeNode 'A' 'Z') 191 | , typeProto .notInCharRange (NotInCharRangeNode 'A' 'Z') 192 | , typeProto .notInCharset (NotInCharSetNode ";:!?") 193 | 194 | , typeProto .optional (OptionalNode { expression = Nothing, minimal = False }) 195 | , typeProto .atLeastOne (AtLeastOneNode { expression = Nothing, minimal = False }) 196 | , typeProto .anyRepetition (AnyRepetitionNode { expression = Nothing, minimal = False }) 197 | 198 | , symbolProto .end End 199 | , symbolProto .start Start 200 | 201 | , symbolProto .wordBoundary WordBoundary 202 | , symbolProto .nonWordBoundary NonWordBoundary 203 | 204 | , typeProto .rangedRepetition (RangedRepetitionNode { expression = Nothing, minimum = 2, maximum = 4, minimal = False }) 205 | , typeProto .minimumRepetition (MinimumRepetitionNode { expression = Nothing, count = 2, minimal = False }) 206 | , typeProto .maximumRepetition (MaximumRepetitionNode { expression = Nothing, count = 4, minimal = False }) 207 | , typeProto .exactRepetition (ExactRepetitionNode { expression = Nothing, count = 3 }) 208 | 209 | , symbolProto .word WordChar 210 | , symbolProto .nonWord NonWordChar 211 | , symbolProto .lineBreak LinebreakChar 212 | , symbolProto .nonLineBreak NonLinebreakChar 213 | , symbolProto .tab TabChar 214 | 215 | , typeProto .flags (FlagsNode { expression = Nothing, flags = defaultFlags }) 216 | , typeProto .capture (CaptureNode Nothing) 217 | 218 | , symbolProto .none Never 219 | , symbolProto .any Always 220 | 221 | 222 | , typeProto .ifFollowedBy (IfFollowedByNode { expression = Nothing, successor = Nothing }) 223 | , typeProto .ifNotFollowedBy (IfNotFollowedByNode { expression = Nothing, successor = Nothing }) 224 | ] 225 | 226 | 227 | typeProto getter prototype = Prototype (getter typeNames) prototype (getter typeDescriptions) 228 | symbolProto getter symbol = Prototype (getter symbolNames) (SymbolNode symbol) (getter symbolDescriptions) 229 | 230 | 231 | symbolNames = 232 | { whitespace = "Whitespace Char" 233 | , nonWhitespace = "Non Whitespace Char" 234 | , digit = "Digit Char" 235 | , nonDigit = "Non Digit Char" 236 | , word = "Word Char" 237 | , nonWord = "Non Word Char" 238 | , wordBoundary = "Word Boundary" 239 | , nonWordBoundary = "Non Word Boundary" 240 | , lineBreak = "Linebreak Char" 241 | , nonLineBreak = "Non Linebreak Char" 242 | , tab = "Tab Char" 243 | , none = "Nothing" 244 | , any = "Anything" 245 | , end = "End of Text" 246 | , start = "Start of Text" 247 | } 248 | 249 | typeNames = 250 | { charset = "Any of Chars" 251 | , notInCharset = "None of Chars" 252 | , literal = "Literal" 253 | , charRange = "Any of Char Range" 254 | , notInCharRange = "None of Char Range" 255 | , optional = "Optional" 256 | , set = "Any Of" 257 | , capture = "Capture" 258 | , ifNotFollowedBy = "If Not Followed By" 259 | , sequence = "Sequence" 260 | , flags = "Configuration" 261 | , exactRepetition = "Exact Repetition" 262 | , atLeastOne = "At Least One" 263 | , anyRepetition = "Any Repetition" 264 | , minimumRepetition = "Minimum Repetition" 265 | , maximumRepetition = "Maximum Repetition" 266 | , rangedRepetition = "Ranged Repetition" 267 | , ifFollowedBy = "If Followed By" 268 | } 269 | 270 | symbolDescriptions = 271 | { whitespace = "Match any invisible char, such as the space between words and linebreaks" 272 | , nonWhitespace = "Match any char that is not invisible, for example neither space nor linebreaks" 273 | , digit = "Match any numerical char, from `0` to `9`, excluding punctuation" 274 | , nonDigit = "Match any char but numerical ones, matching punctuation" 275 | , word = "Match any alphabetical chars, and the underscore char `_`" 276 | , nonWord = "Match any char, but not alphabetical ones and not the underscore char `_`" 277 | , wordBoundary = "Matches where a word char has a whitespace neighbour" 278 | , nonWordBoundary = "Matches anywhere but not where a word char has a whitespace neighbour" 279 | , lineBreak = "Matches the linebreak, or newline, char `\\n`" 280 | , nonLineBreak = "Matches anything but the linebreak char `\\n`" 281 | , tab = "Matches the tab char `\\t`" 282 | , none = "Matches nothing ever, really" 283 | , any = "Matches any char, including linebreaks and whitespace" 284 | , end = "The end of line if Multiline Matches are enabled, or the end of the text otherwise" 285 | , start = "The start of line if Multiline Matches are enabled, or the start of the text otherwise" 286 | } 287 | 288 | 289 | typeDescriptions = 290 | { charset = "Matches, where any char of the set is matched" 291 | , notInCharset = "Matches where not a single char of the set is matched" 292 | , literal = "Matches where that exact sequence of chars is found" 293 | , charRange = "Matches any char between the lower and upper range bound" 294 | , notInCharRange = "Matches any char outside of the range" 295 | , optional = "Allow omitting this expression and match anyways" 296 | , set = "Matches, where at least one of the options matches" 297 | , capture = "Capture this expression for later use, like replacing" 298 | , ifNotFollowedBy = "Match this expression only if the successor is not matched, without matching the successor itself" 299 | , sequence = "Matches, where all members in the exact order are matched one after another" 300 | , flags = "Configure how the whole regex operates" 301 | , exactRepetition = "Match where an expression is repeated exactly n times" 302 | , atLeastOne = "Allow this expression to occur multiple times" 303 | , anyRepetition = "Allow this expression to occur multiple times or not at all" 304 | , minimumRepetition = "Match where an expression is repeated at least n times" 305 | , maximumRepetition = "Match where an expression is repeated no more than n times" 306 | , rangedRepetition = "Only match if the expression is repeated in range" 307 | , ifFollowedBy = "Match this expression only if the successor is also matched, without matching the successor itself" 308 | } 309 | 310 | 311 | type alias RegexFlags = 312 | { multiple : Bool 313 | , caseInsensitive : Bool 314 | , multiline : Bool 315 | } 316 | 317 | 318 | viewTransform { magnification, offset} = 319 | { translate = offset 320 | , scale = 2 ^ (magnification * 0.4) 321 | } 322 | 323 | defaultFlags = RegexFlags True False True 324 | 325 | 326 | summary node = case node of 327 | LiteralNode literal -> Just ("`" ++ literal ++ "`") 328 | CharSetNode options -> Just ("One of `" ++ options ++ "`") 329 | NotInCharSetNode options -> Just ("None of `" ++ options ++ "`") 330 | CharRangeNode start end -> Just ("One of `" ++ String.fromChar start ++ "` - `" ++ String.fromChar end ++ "`") 331 | NotInCharRangeNode start end -> Just ("None of `" ++ String.fromChar start ++ "` - `" ++ String.fromChar end ++ "`") 332 | _ -> Nothing 333 | 334 | 335 | toPattern nodeId nodes = case IdMap.get nodeId nodes of 336 | Just (CharRangeNode 'a' 'z') -> Just MinimalAlphabetLowercase 337 | Just (CharRangeNode 'A' 'Z') -> Just MinimalAlphabetUppercase 338 | _ -> Nothing 339 | 340 | 341 | symbolName = symbolProperty symbolNames 342 | symbolDescription = symbolProperty symbolDescriptions 343 | 344 | symbolProperty properties symbol = case symbol of 345 | WhitespaceChar -> properties.whitespace 346 | NonWhitespaceChar -> properties.nonWhitespace 347 | DigitChar -> properties.digit 348 | NonDigitChar -> properties.nonDigit 349 | WordChar -> properties.word 350 | NonWordChar -> properties.nonWord 351 | WordBoundary -> properties.wordBoundary 352 | NonWordBoundary -> properties.nonWordBoundary 353 | LinebreakChar -> properties.lineBreak 354 | NonLinebreakChar -> properties.nonLineBreak 355 | TabChar -> properties.tab 356 | Never -> properties.none 357 | Always -> properties.any 358 | Start -> properties.start 359 | End -> properties.end 360 | 361 | -- TODO DRY, like: getProperties: Node -> List Property, 362 | -- type alias Property = { inputs, name, description, has Output, updateValue ... } 363 | 364 | 365 | 366 | type alias Property = 367 | { name : String 368 | , description: String 369 | , contents : PropertyValue 370 | , connectOutput : Bool 371 | } 372 | 373 | -- if a property must be updated, return the whole new node 374 | type alias OnChange a = a -> Node 375 | 376 | type PropertyValue 377 | = BoolProperty Bool (OnChange Bool) 378 | | CharsProperty String (OnChange String) 379 | | CharProperty Char (OnChange Char) 380 | | IntProperty Int (OnChange Int) 381 | | ConnectingProperty (Maybe NodeId) (OnChange (Maybe NodeId)) 382 | | ConnectingProperties Bool (Array NodeId) (OnChange (Array NodeId)) 383 | | TitleProperty 384 | 385 | 386 | nodeProperties : Node -> List Property 387 | nodeProperties node = 388 | case node of 389 | SymbolNode symbol -> [ Property (symbolName symbol) (symbolDescription symbol) TitleProperty True ] 390 | 391 | CharSetNode chars -> 392 | [ Property typeNames.charset 393 | ("Matches " ++ String.join ", " (String.toList chars |> List.map String.fromChar)) 394 | (CharsProperty chars CharSetNode) True 395 | ] 396 | 397 | NotInCharSetNode chars -> [ Property typeNames.notInCharset 398 | ("Matches any char but " ++ String.join ", " (String.toList chars |> List.map String.fromChar)) 399 | (CharsProperty chars NotInCharSetNode) True ] 400 | 401 | LiteralNode literal -> 402 | [ Property typeNames.literal ("Matches exactly `" ++ literal ++ "` and nothing else") 403 | (CharsProperty literal LiteralNode) True 404 | ] 405 | 406 | CaptureNode captured -> 407 | [ Property typeNames.capture typeDescriptions.capture 408 | (ConnectingProperty captured CaptureNode) True 409 | ] 410 | 411 | CharRangeNode start end -> 412 | [ Property typeNames.charRange 413 | ("Match any char whose integer value is equal to or between " ++ String.fromChar start ++ " and " ++ String.fromChar end) 414 | TitleProperty True 415 | 416 | , Property "First Char" "The lower range bound char, will match itself" (CharProperty start (updateCharRangeFirst end)) False 417 | , Property "Last Char" "The upper range bound char, will match itself" (CharProperty end (updateCharRangeLast start)) False 418 | ] 419 | 420 | NotInCharRangeNode start end -> 421 | [ Property typeNames.notInCharRange 422 | ("Match any char whose integer value is neither equal to nor between " ++ String.fromChar start ++ " and " ++ String.fromChar end) 423 | TitleProperty True 424 | 425 | , Property "First Char" "The lower range bound char, will not match itself" (CharProperty start (updateNotInCharRangeFirst end)) False 426 | , Property "Last Char" "The upper range bound char, will not match itself" (CharProperty end (updateNotInCharRangeLast start)) False 427 | ] 428 | 429 | SetNode options -> 430 | [ Property typeNames.set typeDescriptions.set TitleProperty True 431 | , Property "•" "Match if this or any other option is matched" (ConnectingProperties False options SetNode) False 432 | ] 433 | 434 | SequenceNode members -> 435 | [ Property typeNames.sequence typeDescriptions.sequence TitleProperty True 436 | , Property "and then" "A member of the sequence" (ConnectingProperties True members SequenceNode) False 437 | ] 438 | 439 | FlagsNode flagsNode -> 440 | [ Property typeNames.flags typeDescriptions.flags 441 | (ConnectingProperty flagsNode.expression (updateFlagsExpression flagsNode)) False 442 | 443 | , Property "Multiple Matches" "Do not stop after the first match" 444 | (BoolProperty flagsNode.flags.multiple (updateFlagsMultiple flagsNode)) False 445 | 446 | , Property "Case Insensitive" "Match as if everything had the same case" 447 | (BoolProperty flagsNode.flags.caseInsensitive (updateFlagsInsensitivity flagsNode)) False 448 | 449 | , Property "Multiline Matches" "Allow every matches to be found across multiple lines" 450 | (BoolProperty flagsNode.flags.multiline (updateFlagsMultiline flagsNode)) False 451 | ] 452 | 453 | IfFollowedByNode followed -> 454 | [ Property typeNames.ifFollowedBy typeDescriptions.ifFollowedBy 455 | (ConnectingProperty followed.expression (IfFollowedByNode << updateExpression followed)) True 456 | 457 | , Property "Successor" "What needs to follow the expression" 458 | (ConnectingProperty followed.successor (IfFollowedByNode << updateSuccessor followed)) False 459 | ] 460 | 461 | IfNotFollowedByNode followed -> 462 | [ Property typeNames.ifNotFollowedBy typeDescriptions.ifNotFollowedBy 463 | (ConnectingProperty followed.expression (IfNotFollowedByNode << updateExpression followed)) True 464 | 465 | , Property "Successor" "What must not follow the expression" 466 | (ConnectingProperty followed.successor (IfNotFollowedByNode << updateSuccessor followed)) False 467 | ] 468 | 469 | OptionalNode option -> 470 | [ Property typeNames.optional typeDescriptions.optional 471 | (ConnectingProperty option.expression (OptionalNode << updateExpression option)) True 472 | 473 | , Property "Prefer None" "Prefer not to match" 474 | (BoolProperty option.minimal (OptionalNode << updateMinimal option)) False 475 | ] 476 | 477 | AtLeastOneNode counted -> 478 | [ Property typeNames.atLeastOne typeDescriptions.atLeastOne 479 | (ConnectingProperty counted.expression (AtLeastOneNode << updateExpression counted)) True 480 | 481 | , Property "Minimize Count" "Match as few occurences as possible" 482 | (BoolProperty counted.minimal (AtLeastOneNode << updateMinimal counted)) False 483 | ] 484 | 485 | AnyRepetitionNode counted -> 486 | [ Property typeNames.anyRepetition typeDescriptions.anyRepetition 487 | (ConnectingProperty counted.expression (AnyRepetitionNode << updateExpression counted)) True 488 | 489 | , Property "Minimize Count" "Match as few occurences as possible" 490 | (BoolProperty counted.minimal (AnyRepetitionNode << updateMinimal counted)) False 491 | ] 492 | 493 | ExactRepetitionNode repetition -> 494 | [ Property typeNames.exactRepetition 495 | ("Match only if this expression is repeated exactly " ++ String.fromInt repetition.count ++ " times") 496 | (ConnectingProperty repetition.expression (ExactRepetitionNode << updateExpression repetition)) True 497 | 498 | , Property "Count" "How often the expression is required" 499 | (IntProperty repetition.count (ExactRepetitionNode << updatePositiveCount repetition)) False 500 | ] 501 | 502 | RangedRepetitionNode counted -> 503 | [ Property typeNames.rangedRepetition 504 | ("Match only if the expression is repeated no less than " ++ String.fromInt counted.minimum 505 | ++ " and no more than " ++ String.fromInt counted.maximum ++ " times" 506 | ) 507 | (ConnectingProperty counted.expression (RangedRepetitionNode << updateExpression counted)) True 508 | 509 | , Property "Minimum" 510 | ("Match only if the expression is repeated no less than " ++ String.fromInt counted.minimum ++ " times") 511 | (IntProperty counted.minimum (updateRangedRepetitionMinimum counted)) False 512 | 513 | , Property "Maximum" 514 | ("Match only if the expression is repeated no more than " ++ String.fromInt counted.maximum ++ " times") 515 | (IntProperty counted.maximum (updateRangedRepetitionMaximum counted)) False 516 | 517 | , Property "Minimize Count" "Match as few occurences as possible" 518 | (BoolProperty counted.minimal (RangedRepetitionNode << updateMinimal counted)) False 519 | ] 520 | 521 | MinimumRepetitionNode counted -> 522 | [ Property typeNames.minimumRepetition 523 | ("Match only if the expression is repeated no less than " ++ String.fromInt counted.count ++ " times") 524 | (ConnectingProperty counted.expression (MinimumRepetitionNode << updateExpression counted)) True 525 | 526 | , Property "Count" "Minimum number of repetitions" 527 | (IntProperty counted.count (MinimumRepetitionNode << updatePositiveCount counted)) False 528 | 529 | , Property "Minimize Count" "Match as few occurences as possible" 530 | (BoolProperty counted.minimal (MinimumRepetitionNode << updateMinimal counted)) False 531 | ] 532 | 533 | MaximumRepetitionNode counted -> 534 | [ Property typeNames.maximumRepetition 535 | ("Match only if the expression is repeated no more than " ++ String.fromInt counted.count ++ " times") 536 | (ConnectingProperty counted.expression (MaximumRepetitionNode << updateExpression counted)) True 537 | 538 | , Property "Count" "Maximum number of repetitions" 539 | (IntProperty counted.count (MaximumRepetitionNode << updatePositiveCount counted)) False 540 | 541 | , Property "Minimize Count" "Match as few occurences as possible" 542 | (BoolProperty counted.minimal (MaximumRepetitionNode << updateMinimal counted)) False 543 | ] 544 | 545 | -- SubResultNode result -> 546 | -- [ Property (compileRegexString expression nodes) 547 | -- ("This property shows the connected expression") 548 | -- (ConnectingProperty result.expression (SubResultNode << updateExpression result)) True 549 | -- ] 550 | 551 | 552 | -- TODO implement generically using (nodeProperties node) 553 | onNodeDeleted : NodeId -> Node -> Node 554 | onNodeDeleted deleted node = 555 | case node of 556 | SymbolNode _ -> node 557 | CharSetNode _ -> node 558 | NotInCharSetNode _ -> node 559 | LiteralNode _ -> node 560 | CharRangeNode _ _ -> node 561 | NotInCharRangeNode _ _ -> node 562 | 563 | SetNode members -> SetNode <| Array.filter ((/=) deleted) members 564 | SequenceNode members -> SequenceNode <| Array.filter ((/=) deleted) members 565 | CaptureNode child -> CaptureNode <| ifNotDeleted deleted child 566 | 567 | OptionalNode child -> OptionalNode <| ifExpressionNotDeleted deleted child 568 | AtLeastOneNode child -> AtLeastOneNode <| ifExpressionNotDeleted deleted child 569 | AnyRepetitionNode child -> AnyRepetitionNode <| ifExpressionNotDeleted deleted child 570 | FlagsNode value -> FlagsNode <| ifExpressionNotDeleted deleted value 571 | IfFollowedByNode value -> IfFollowedByNode <| ifExpressionNotDeleted deleted value 572 | IfNotFollowedByNode value -> IfNotFollowedByNode <| ifExpressionNotDeleted deleted value 573 | RangedRepetitionNode value -> RangedRepetitionNode <| ifExpressionNotDeleted deleted value 574 | MinimumRepetitionNode value -> MinimumRepetitionNode <| ifExpressionNotDeleted deleted value 575 | MaximumRepetitionNode value -> MaximumRepetitionNode <| ifExpressionNotDeleted deleted value 576 | ExactRepetitionNode value -> ExactRepetitionNode <| ifExpressionNotDeleted deleted value 577 | 578 | 579 | 580 | ifNotDeleted deleted node = 581 | if node == Just deleted 582 | then Nothing else node 583 | 584 | ifExpressionNotDeleted deleted values = 585 | { values | expression = ifNotDeleted deleted values.expression } 586 | 587 | 588 | insertWhitePlaceholder = String.replace " " "␣" 589 | removeWhitePlaceholder = String.replace "␣" " " 590 | 591 | 592 | updateExpression node expression = { node | expression = expression } 593 | updateSuccessor node successor = { node | successor = successor } 594 | updateMinimal node minimal = { node | minimal = minimal } 595 | 596 | updatePositiveCount node count = { node | count = positive count } 597 | 598 | updateCharRangeFirst end start = CharRangeNode (minChar start end) (maxChar start end) -- swaps chars if necessary 599 | updateCharRangeLast start end = CharRangeNode (minChar end start) (maxChar start end) -- swaps chars if necessary 600 | 601 | updateNotInCharRangeFirst end start = NotInCharRangeNode (minChar start end) (maxChar start end) -- swaps chars if necessary 602 | updateNotInCharRangeLast start end = NotInCharRangeNode (minChar end start) (maxChar start end) -- swaps chars if necessary 603 | 604 | updateRangedRepetitionMinimum repetition count = RangedRepetitionNode 605 | { repetition | minimum = positive count, maximum = max (positive count) repetition.maximum } 606 | 607 | updateRangedRepetitionMaximum repetition count = RangedRepetitionNode 608 | { repetition | maximum = positive count, minimum = min (positive count) repetition.minimum } 609 | 610 | updateFlagsExpression flags newInput = FlagsNode { flags | expression = newInput } 611 | updateFlags expression newFlags = FlagsNode { expression = expression, flags = newFlags } 612 | updateFlagsMultiple { expression, flags } multiple = updateFlags expression { flags | multiple = multiple } 613 | updateFlagsInsensitivity { expression, flags } caseInsensitive = updateFlags expression { flags | caseInsensitive = caseInsensitive } 614 | updateFlagsMultiline { expression, flags } multiline = updateFlags expression { flags | multiline = multiline } 615 | 616 | positive = Basics.max 0 617 | minChar a b = if a < b then a else b 618 | maxChar a b = if a > b then a else b 619 | 620 | 621 | -- LAYOUT 622 | 623 | propertyHeight = 25 624 | 625 | nodeWidth node = case node of 626 | SymbolNode symbol -> symbol |> symbolName |> mainTextWidth 627 | CharSetNode chars -> mainTextWidth typeNames.charset + codeTextWidth chars + 3 628 | NotInCharSetNode chars -> mainTextWidth typeNames.charset + codeTextWidth chars + 3 629 | CharRangeNode _ _ -> mainTextWidth typeNames.charRange 630 | NotInCharRangeNode _ _ -> mainTextWidth typeNames.notInCharRange 631 | LiteralNode chars -> mainTextWidth typeNames.literal + codeTextWidth chars + 3 632 | OptionalNode _ -> stringWidth 10 633 | SetNode _ -> mainTextWidth typeNames.set 634 | FlagsNode _ -> mainTextWidth typeNames.flags 635 | IfFollowedByNode _ -> mainTextWidth typeNames.ifFollowedBy 636 | ExactRepetitionNode _ -> mainTextWidth typeNames.exactRepetition 637 | SequenceNode _ -> mainTextWidth typeNames.sequence 638 | CaptureNode _ -> mainTextWidth typeNames.capture 639 | IfNotFollowedByNode _ -> mainTextWidth typeNames.ifNotFollowedBy 640 | AtLeastOneNode _ -> mainTextWidth typeNames.atLeastOne 641 | AnyRepetitionNode _ -> mainTextWidth typeNames.anyRepetition 642 | RangedRepetitionNode _ -> mainTextWidth typeNames.rangedRepetition 643 | MinimumRepetitionNode _ -> mainTextWidth typeNames.minimumRepetition 644 | MaximumRepetitionNode _ -> mainTextWidth typeNames.maximumRepetition 645 | 646 | 647 | 648 | -- Thanks, Html, for letting us hardcode those values <3 649 | codeTextWidth = String.length >> (*) 5 >> toFloat 650 | mainTextWidth text = text |> String.length |> toFloat |> stringWidth 651 | stringWidth length = (Basics.max 5 length) * (if length < 14 then 13 else 9) 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 667 | -------------------------------------------------------------------------------- /src/Parse.elm: -------------------------------------------------------------------------------- 1 | module Parse exposing (..) 2 | 3 | import Array 4 | import IdMap 5 | import Model exposing (..) 6 | import Result 7 | import Vec2 exposing (Vec2) 8 | import LinearDict exposing (LinearDict) 9 | 10 | type alias ParseResult a = Result ParseError a 11 | type alias ParseSubResult a = ParseResult (a, String) 12 | 13 | type ParseError 14 | = ExpectedMoreChars 15 | | Expected String 16 | 17 | 18 | -- TODO this parsing code is more complex than it needs to be (brackets must always be escaped and such) 19 | 20 | -- TODO not all-or-nothing, but try the best guess at invalid input! 21 | 22 | -- closer to the textual JS regex than to the node graph 23 | type ParsedElement 24 | = ParsedSequence (List ParsedElement) 25 | | ParsedCharsetAtom CharsetAtom 26 | | ParsedCharset { inverted: Bool, contents: List CharsetAtom } 27 | | ParsedSet (List ParsedElement) 28 | | ParsedCapture ParsedElement 29 | | ParsedIfFollowedBy { expression: ParsedElement, successor: ParsedElement } 30 | | ParsedIfNotFollowedBy { expression: ParsedElement, successor: ParsedElement } 31 | | ParsedRangedRepetition { expression : ParsedElement, minimum: Int, maximum: Int, minimal: Bool } 32 | | ParsedMinimumRepetition { expression : ParsedElement, count: Int, minimal: Bool } 33 | | ParsedExactRepetition { expression : ParsedElement, count: Int } 34 | | ParsedAnyRepetition { expression : ParsedElement, minimal: Bool } 35 | | ParsedAtLeastOne { expression : ParsedElement, minimal: Bool } 36 | | ParsedOptional { expression: ParsedElement, minimal: Bool } 37 | | ParsedFlags { expression : ParsedElement, flags : RegexFlags } 38 | 39 | 40 | -- closer to the node graph than to the JS regex 41 | type CompiledElement 42 | = CompiledSequence (List CompiledElement) 43 | | CompiledSymbol Symbol 44 | | CompiledCharRange Bool (Char, Char) 45 | | CompiledCharSequence String 46 | | CompiledCharset { inverted: Bool, contents: String } 47 | | CompiledSet (List CompiledElement) 48 | | CompiledCapture CompiledElement 49 | | CompiledIfFollowedBy { expression: CompiledElement, successor: CompiledElement } 50 | | CompiledIfNotFollowedBy { expression: CompiledElement, successor: CompiledElement } 51 | | CompiledRangedRepetition { expression : CompiledElement, minimum: Int, maximum: Int, minimal: Bool } 52 | | CompiledMinimumRepetition { expression : CompiledElement, count: Int, minimal: Bool } 53 | | CompiledExactRepetition { expression : CompiledElement, count: Int } 54 | | CompiledOptional { expression: CompiledElement, minimal: Bool } 55 | | CompiledAtLeastOne { expression: CompiledElement, minimal: Bool } 56 | | CompiledAnyRepetition { expression: CompiledElement, minimal: Bool } 57 | | CompiledFlags { expression : CompiledElement, flags : RegexFlags } 58 | 59 | 60 | type CharsetAtom = Plain Char | Escaped Symbol | Range (Char, Char) 61 | 62 | addParsedRegexNode : Vec2 -> Nodes -> String -> ParseResult (NodeId, Nodes) 63 | addParsedRegexNode position nodes regex = parse regex 64 | |> Result.map (compile >> addCompiledElement position nodes) 65 | 66 | addCompiledElement : Vec2 -> Nodes -> CompiledElement -> (NodeId, Nodes) 67 | addCompiledElement position nodes parsed = let (a,b,_) = insert position parsed nodes LinearDict.empty in (a,b) 68 | 69 | 70 | type alias DuplicationGuard = LinearDict CompiledElement NodeId 71 | 72 | insert : Vec2 -> CompiledElement -> Nodes -> DuplicationGuard -> (NodeId, Nodes, DuplicationGuard) 73 | insert position element nodes guard = 74 | let 75 | simpleNode = NodeView position 76 | simpleInsert = insert position 77 | 78 | in case element of 79 | CompiledSequence members -> 80 | let 81 | (children, newNodes, newGuard) = insertElements members nodes guard 82 | nodeValue = children |> Array.fromList |> SequenceNode |> simpleNode 83 | in insertElement nodeValue newNodes element newGuard 84 | 85 | CompiledCharSequence sequence -> insertElement 86 | (simpleNode (LiteralNode sequence)) nodes element guard 87 | 88 | CompiledSymbol symbol -> insertElement 89 | (simpleNode (SymbolNode symbol)) nodes element guard 90 | 91 | CompiledCharRange inverted (a, b) -> insertElement 92 | (simpleNode ((if inverted then NotInCharRangeNode else CharRangeNode) a b)) nodes element guard 93 | 94 | CompiledCapture child -> 95 | let (childId, nodesWithChild, guardWithChild) = simpleInsert child nodes guard -- children will be reused if possible 96 | in insertElement (simpleNode (CaptureNode <| Just childId)) nodesWithChild element guardWithChild 97 | 98 | CompiledCharset { inverted, contents } -> insertElement 99 | (simpleNode (if inverted then NotInCharSetNode contents else CharSetNode contents)) nodes element guard 100 | 101 | CompiledSet options -> 102 | let 103 | (children, newNodes, newGuard) = insertElements options nodes guard 104 | nodeValue = children |> Array.fromList |> SetNode |> simpleNode 105 | in insertElement nodeValue newNodes element newGuard 106 | 107 | CompiledIfFollowedBy { expression, successor } -> 108 | let 109 | (expressionId, nodesWithExpression, guard1) = simpleInsert expression nodes guard -- children will be reused if possible 110 | (successorId, nodesWithChildren, guard2) = simpleInsert successor nodesWithExpression guard1 111 | in insertElement 112 | (simpleNode (IfFollowedByNode { expression = Just expressionId, successor = Just successorId })) 113 | nodesWithChildren element guard2 114 | 115 | CompiledIfNotFollowedBy { expression, successor } -> 116 | let 117 | (expressionId, nodesWithExpression, guard1) = simpleInsert expression nodes guard 118 | (successorId, nodesWithChildren, guard2) = simpleInsert successor nodesWithExpression guard1 119 | in insertElement 120 | (simpleNode (IfNotFollowedByNode { expression = Just expressionId, successor = Just successorId })) 121 | nodesWithChildren element guard2 122 | 123 | CompiledRangedRepetition { expression, minimum, maximum, minimal } -> 124 | let (expressionId, nodesWithChild, guard1) = simpleInsert expression nodes guard 125 | in insertElement 126 | (simpleNode (RangedRepetitionNode { expression = Just expressionId, minimum = minimum, maximum = maximum, minimal = minimal })) 127 | nodesWithChild element guard1 128 | 129 | CompiledExactRepetition { expression, count } -> 130 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard 131 | in insertElement 132 | (simpleNode (ExactRepetitionNode { expression = Just expressionId, count = count })) 133 | nodesWithChild element guardWithChild 134 | 135 | CompiledMinimumRepetition { expression, count, minimal } -> 136 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard 137 | in insertElement 138 | (simpleNode (MinimumRepetitionNode { expression = Just expressionId, count = count, minimal = minimal })) 139 | nodesWithChild element guardWithChild 140 | 141 | CompiledOptional { expression, minimal } -> 142 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard 143 | in insertElement 144 | (simpleNode (OptionalNode { expression = Just expressionId, minimal = minimal })) 145 | nodesWithChild element guardWithChild 146 | 147 | CompiledAtLeastOne { expression, minimal } -> 148 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard 149 | in insertElement 150 | (simpleNode (AtLeastOneNode { expression = Just expressionId, minimal = minimal })) 151 | nodesWithChild element guardWithChild 152 | 153 | CompiledAnyRepetition { expression, minimal } -> 154 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard 155 | in insertElement 156 | (simpleNode (AnyRepetitionNode { expression = Just expressionId, minimal = minimal })) 157 | nodesWithChild element guardWithChild 158 | 159 | -- TODO DRY 160 | CompiledFlags { expression, flags } -> 161 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard 162 | in insertElement 163 | (simpleNode (FlagsNode { expression = Just expressionId, flags = flags })) 164 | nodesWithChild element guardWithChild 165 | 166 | 167 | insertElement: NodeView -> Nodes -> CompiledElement -> DuplicationGuard -> (NodeId, Nodes, DuplicationGuard) 168 | insertElement newNode currentNodes newElement currentGuard = 169 | if isSimple newElement -- FIXME this simplification will sometimes lead to new but completely unconnected nodes?? 170 | then insertNewElement newNode currentNodes newElement currentGuard 171 | 172 | else case LinearDict.get newElement currentGuard of 173 | Just existingId -> -- reconnect to existing node 174 | (existingId, currentNodes, currentGuard) 175 | 176 | Nothing -> insertNewElement newNode currentNodes newElement currentGuard 177 | 178 | 179 | insertNewElement newNode currentNodes newElement currentGuard = 180 | let -- actually insert new 181 | (id, map) = currentNodes |> IdMap.insert newNode 182 | newGuard = currentGuard |> LinearDict.insert newElement id 183 | in (id, map, newGuard) 184 | 185 | insertElements: List CompiledElement -> Nodes -> DuplicationGuard 186 | -> (List NodeId, Nodes, DuplicationGuard) 187 | 188 | insertElements newNodeIds currentNodes currentGuard = 189 | case newNodeIds of 190 | [] -> ([], currentNodes, currentGuard) 191 | 192 | element :: rest -> 193 | let 194 | (restIds, restNodes, restGuards) = insertElements rest currentNodes currentGuard 195 | (id, newNodes, newGuard) = insert Vec2.zero element restNodes restGuards 196 | 197 | in (id :: restIds, newNodes, newGuard) 198 | 199 | -- used to determine whether a node should be reused or (for simple nodes) just inserted again 200 | isSimple node = 201 | case node of 202 | CompiledCharSequence sequence -> String.length sequence < 4 203 | CompiledCharset options -> String.length options.contents < 4 204 | CompiledCharRange _ range -> range == ('a', 'z') || range == ('A', 'Z') || range == ('0', '9') 205 | CompiledSymbol _ -> True 206 | _ -> False 207 | 208 | -- TODO DRY 209 | compile : ParsedElement -> CompiledElement 210 | compile element = case element of 211 | 212 | ParsedSet [ onlyOneOption ] -> compile onlyOneOption 213 | ParsedSet options -> CompiledSet <| List.map compile options 214 | ParsedSequence manyMembers -> compileSequence manyMembers 215 | ParsedCapture child -> CompiledCapture <| compile child 216 | ParsedCharsetAtom charOrSymbol -> compileCharOrSymbol charOrSymbol 217 | ParsedCharset set -> compileCharset set 218 | 219 | ParsedIfFollowedBy { expression, successor } -> CompiledIfFollowedBy 220 | { expression = compile expression, successor = compile successor } 221 | 222 | ParsedIfNotFollowedBy { expression, successor } -> CompiledIfNotFollowedBy 223 | { expression = compile expression, successor = compile successor } 224 | 225 | ParsedRangedRepetition { expression, minimum, maximum, minimal } -> CompiledRangedRepetition 226 | { expression = compile expression, minimum = minimum, maximum = maximum, minimal = minimal } 227 | 228 | ParsedExactRepetition { expression, count } -> CompiledExactRepetition 229 | { expression = compile expression, count = count } 230 | 231 | ParsedMinimumRepetition { expression, count, minimal } -> CompiledMinimumRepetition 232 | { expression = compile expression, count = count, minimal = minimal } 233 | 234 | ParsedOptional { expression, minimal } -> CompiledOptional 235 | { expression = compile expression, minimal = minimal } 236 | 237 | ParsedAnyRepetition { expression, minimal } -> CompiledAnyRepetition 238 | { expression = compile expression, minimal = minimal } 239 | 240 | ParsedAtLeastOne { expression, minimal } -> CompiledAtLeastOne 241 | { expression = compile expression, minimal = minimal } 242 | 243 | ParsedFlags { expression, flags } -> CompiledFlags 244 | { expression = compile expression, flags = flags } 245 | 246 | 247 | 248 | 249 | compileCharOrSymbol charOrSymbol = case charOrSymbol of 250 | Plain char -> CompiledCharSequence <| String.fromChar char 251 | Escaped symbol -> CompiledSymbol symbol 252 | Range _ -> CompiledCharSequence "Error" -- should not happen outside of char sets? 253 | 254 | -- collapse all subsequent chars into a literal 255 | compileSequence members = case List.foldr compileSequenceMember [] members of 256 | [ onlyOneMember ] -> onlyOneMember -- must be checked AFTER collapsing members 257 | moreMembers -> CompiledSequence moreMembers 258 | 259 | compileSequenceMember member compiled = case member of 260 | ParsedCharsetAtom (Plain character) -> case compiled of 261 | CompiledCharSequence collapsed :: alreadyCompiled -> 262 | (CompiledCharSequence (String.fromChar character ++ collapsed)) :: alreadyCompiled 263 | 264 | -- cannot simplify chars if followed by something exotic 265 | nonCharsequenceCompiled -> 266 | CompiledCharSequence (String.fromChar character) :: nonCharsequenceCompiled -- ignore anything else 267 | 268 | symbol -> compile symbol :: compiled 269 | 270 | 271 | compileCharset { inverted, contents } = -- FIXME [^ax-z] != [^a]|[^x-z] !!!! 272 | case List.foldr compileCharsetOption ("", [], []) contents of 273 | (chars, [], []) -> CompiledCharset { inverted = inverted, contents = chars } 274 | ("", [symbol], []) -> CompiledSymbol symbol -- FIXME will ignore `inverted` 275 | ("", [], [range]) -> compileRange inverted range 276 | ("", symbols, ranges) -> CompiledSet (List.map CompiledSymbol symbols ++ List.map (CompiledCharRange inverted) ranges) -- FIXME will ignore `inverted` 277 | (chars, symbols, ranges) -> CompiledSet <| 278 | CompiledCharset { inverted = inverted, contents = chars } 279 | :: (List.map CompiledSymbol symbols) -- TODO dry -- FIXME will ignore `inverted` in symbols 280 | ++ (List.map (compileRange inverted) ranges) -- TODO dry -- FIXME will ignore `inverted` in symbols 281 | 282 | -- TODO filter duplicate options 283 | compileCharsetOption member (chars, symbols, ranges) = case member of 284 | Plain char -> (String.fromChar char ++ chars, symbols, ranges) 285 | Escaped symbol -> (chars, symbol :: symbols, ranges) 286 | Range (start, end) -> (chars, symbols, (start, end) :: ranges) 287 | 288 | compileRange inverted range = 289 | case (inverted, range) of 290 | (False, ('0', '9')) -> CompiledSymbol DigitChar 291 | _ -> CompiledCharRange inverted range 292 | 293 | 294 | parse : String -> ParseResult ParsedElement 295 | parse regex = parseFlags regex 296 | 297 | parseFlags : String -> ParseResult ParsedElement 298 | parseFlags text = 299 | let 300 | (hasModifiers, remaining) = skipIfNext "/" text 301 | expression = parseSet remaining 302 | 303 | parseRegexFlags chars = 304 | RegexFlags 305 | (String.contains "g" chars) 306 | (String.contains "i" chars) 307 | (String.contains "m" chars) 308 | 309 | wrapInFlags content flags = 310 | if flags /= defaultFlags 311 | then ParsedFlags { expression = content, flags = flags } 312 | else content 313 | 314 | parseModifiers (content, remaining1) = 315 | let 316 | skipSlash = skipOrErr "/" remaining1 317 | flags = skipSlash |> Result.map parseRegexFlags 318 | 319 | in flags |> Result.map (wrapInFlags content) 320 | 321 | in if hasModifiers 322 | then expression |> Result.andThen parseModifiers 323 | else expression |> Result.map Tuple.first 324 | 325 | 326 | 327 | parseSet : String -> ParseSubResult ParsedElement 328 | parseSet text = 329 | let firstOption = parseSequence text |> Result.map (Tuple.mapFirst List.singleton) 330 | in extendSet firstOption |> Result.map (Tuple.mapFirst ParsedSet) 331 | 332 | extendSet : ParseSubResult (List ParsedElement) -> ParseSubResult (List ParsedElement) 333 | extendSet current = case current of 334 | Err error -> Err error 335 | Ok (options, text) -> 336 | case skipIfNext "|" text of 337 | (True, rest) -> parseSequence rest 338 | |> Result.map (Tuple.mapFirst (appendTo options)) 339 | |> extendSet 340 | 341 | (False, rest) -> 342 | Ok (options, rest) 343 | 344 | 345 | parseSequence: String -> ParseSubResult ParsedElement 346 | parseSequence text = extendSequence (Ok ([], text)) 347 | |> Result.map (Tuple.mapFirst ParsedSequence) 348 | 349 | extendSequence : ParseSubResult (List ParsedElement) -> ParseSubResult (List ParsedElement) 350 | extendSequence current = case current of 351 | Err error -> Err error 352 | Ok (members, text) -> 353 | if String.isEmpty text || String.startsWith ")" text || String.startsWith "|" text || String.startsWith "/" text 354 | then Ok (members, text) 355 | else parseLookAhead text 356 | |> Result.map (Tuple.mapFirst (appendTo members)) 357 | |> extendSequence 358 | 359 | 360 | parseLookAhead : String -> ParseSubResult ParsedElement 361 | parseLookAhead text = 362 | let 363 | expressionResult = parseQuantified text 364 | extract (content, rest) = 365 | if String.startsWith "(?=" rest 366 | then parseParentheses (\s -> ParsedIfFollowedBy { expression = content, successor = s }) "(?=" rest 367 | 368 | else if String.startsWith "(?!" rest 369 | then parseParentheses (\s -> ParsedIfNotFollowedBy { expression = content, successor = s }) "(?!" rest 370 | 371 | else Ok(content, rest) 372 | 373 | in expressionResult |> Result.andThen extract 374 | 375 | 376 | 377 | parseQuantified : String -> ParseSubResult ParsedElement 378 | parseQuantified text = parseOptional text 379 | 380 | 381 | parseOptional : String -> ParseSubResult ParsedElement 382 | parseOptional text = 383 | let 384 | expressionResult = parseAtLeastOne text 385 | parseIt (expression, rest) = 386 | let 387 | (optional, rest1) = skipIfNext "?" rest 388 | (isLazy, rest2) = if optional 389 | then skipIfNext "?" rest1 390 | else (False, rest1) 391 | 392 | in if optional 393 | then (ParsedOptional { expression = expression, minimal = isLazy }, rest2) 394 | else (expression, rest2) 395 | 396 | in expressionResult |> Result.map parseIt 397 | 398 | -- TODO DRY 399 | parseAtLeastOne : String -> ParseSubResult ParsedElement 400 | parseAtLeastOne text = 401 | let 402 | expressionResult = parseAnyRepetition text 403 | parseIt (expression, rest) = 404 | let 405 | (optional, rest1) = skipIfNext "+" rest 406 | (isLazy, rest2) = if optional 407 | then skipIfNext "?" rest1 408 | else (False, rest1) 409 | 410 | in if optional 411 | then (ParsedAtLeastOne { expression = expression, minimal = isLazy }, rest2) 412 | else (expression, rest2) 413 | 414 | in expressionResult |> Result.map parseIt 415 | 416 | 417 | parseAnyRepetition : String -> ParseSubResult ParsedElement 418 | parseAnyRepetition text = 419 | let 420 | expressionResult = parseRangedRepetition text 421 | parseIt (expression, rest) = 422 | let 423 | (repeat, rest1) = skipIfNext "*" rest 424 | (isLazy, rest2) = if repeat 425 | then skipIfNext "?" rest1 426 | else (False, rest1) 427 | 428 | in if repeat 429 | then (ParsedAnyRepetition { expression = expression, minimal = isLazy }, rest2) 430 | else (expression, rest2) 431 | 432 | in expressionResult |> Result.map parseIt 433 | 434 | 435 | parseRangedRepetition : String -> ParseSubResult ParsedElement 436 | parseRangedRepetition text = 437 | text |> parseAtom |> Result.andThen (\(atom, rest) -> 438 | let 439 | (started, rest1) = skipIfNext "{" rest 440 | range = if started 441 | then Just (parseRepetitionRange rest1) 442 | else Nothing 443 | 444 | toNodes repetition = 445 | case repetition of 446 | Exact count -> ParsedExactRepetition 447 | { expression = atom, count = count } 448 | 449 | Ranged (min, max, minimal) -> case max of 450 | Nothing -> ParsedMinimumRepetition 451 | { expression = atom, count = min, minimal = minimal } 452 | 453 | Just maximum -> ParsedRangedRepetition 454 | { expression = atom, minimum = min, maximum = maximum, minimal = minimal } 455 | 456 | in case range of 457 | Nothing -> Ok (atom, rest) 458 | Just result -> result |> Result.map (Tuple.mapFirst toNodes) 459 | ) 460 | 461 | 462 | type Repetition = Exact Int | Ranged (Int, Maybe Int, Bool) 463 | parseRepetitionRange : String -> ParseSubResult Repetition 464 | parseRepetitionRange text = 465 | let 466 | contents = splitFirst "}" text 467 | parseNumberList = String.split "," >> List.map String.toInt 468 | ranges = contents |> Maybe.map (Tuple.mapFirst parseNumberList) 469 | 470 | in case ranges of 471 | Just ([Just singleCount], rest) -> Ok (Exact singleCount, rest) 472 | 473 | Just ([first, second], rest) -> 474 | let (minimal, rest1) = skipIfNext "?" rest 475 | in Ok (Ranged (Maybe.withDefault 0 first, second, minimal), rest1) 476 | 477 | _ -> Err (Expected "Invalid count specifier") 478 | 479 | 480 | -- split off after the first occurrence of the delimiter, and consume the delimiter 481 | splitFirst delimiter text = 482 | case String.split delimiter text of 483 | first :: rest -> Just (first, String.join delimiter rest) -- FIXME wow 484 | _ -> Nothing 485 | 486 | 487 | -- an atom is any thing which has the highest precedence possible, especially brackets and characters 488 | parseAtom : String -> ParseSubResult ParsedElement 489 | parseAtom text = 490 | let 491 | isNext character = String.startsWith character text 492 | 493 | in 494 | if isNext "[" then parseCharset text 495 | else if isNext "(?:" then parseGroup text 496 | else if isNext "(" then parseCapturingGroup text 497 | else parseGenericAtomicChar text 498 | 499 | 500 | parseGroup = parseParentheses identity "(?:" 501 | parseCapturingGroup = parseParentheses ParsedCapture "(" 502 | 503 | parseParentheses : (ParsedElement -> ParsedElement) -> String -> String -> ParseSubResult ParsedElement 504 | parseParentheses map openParens text = 505 | let 506 | contents = skipOrErr openParens text 507 | result = contents |> Result.andThen parseSet 508 | in result |> Result.map (Tuple.mapSecond (String.dropLeft 1)) -- drop the closing parentheses 509 | |> Result.map (Tuple.mapFirst map) 510 | 511 | parseGenericAtomicChar : String -> ParseSubResult ParsedElement 512 | parseGenericAtomicChar text = 513 | case String.uncons text of 514 | Just ('.', rest) -> Ok (ParsedCharsetAtom <| Escaped NonLinebreakChar, rest) 515 | Just ('$', rest) -> Ok (ParsedCharsetAtom <| Escaped End, rest) 516 | Just ('^', rest) -> Ok (ParsedCharsetAtom <| Escaped Start, rest) 517 | _ -> text 518 | |> parseAtomicChar (maybeOptions symbolizeLetterbased symbolizeTabLinebreak) 519 | |> Result.map (Tuple.mapFirst ParsedCharsetAtom) 520 | 521 | 522 | symbolizeTabLinebreak : Char -> Maybe Symbol 523 | symbolizeTabLinebreak token = case token of 524 | 't' -> Just TabChar 525 | 'n' -> Just LinebreakChar 526 | _ -> Nothing 527 | 528 | 529 | 530 | parseCharset : String -> ParseSubResult ParsedElement 531 | parseCharset text = 532 | let 533 | withoutBracket = skipOrErr "[" text 534 | inversion = withoutBracket |> Result.map (skipIfNext "^") 535 | contents = extendCharset (inversion |> Result.map (Tuple.mapFirst <| always [])) 536 | 537 | charsetFromResults (inverted, _) (options, remaining) = 538 | (ParsedCharset { inverted = inverted, contents = options }, remaining) 539 | 540 | in Result.map2 charsetFromResults inversion contents 541 | 542 | 543 | -- TODO use foldr or similar? 544 | extendCharset : ParseSubResult (List CharsetAtom) -> ParseSubResult (List CharsetAtom) 545 | extendCharset current = case current of 546 | Err error -> Err error 547 | Ok (options, remaining) -> 548 | if String.isEmpty remaining 549 | then Err (Expected "]") 550 | 551 | else case skipIfNext "]" remaining of 552 | (True, rest) -> Ok (options, rest) 553 | (False, rest) -> parseCharsetAtom rest 554 | |> Result.map (Tuple.mapFirst (appendTo options)) 555 | |> extendCharset 556 | 557 | 558 | 559 | parseCharsetAtom : String -> ParseSubResult CharsetAtom 560 | parseCharsetAtom text = 561 | let 562 | atom = parseAtomicChar (maybeOptions symbolizeLetterbased symbolizeTabLinebreakDot) 563 | 564 | extractRange : (Char, String) -> ParseSubResult CharsetAtom 565 | extractRange (firstAtom, remaining) = case skipIfNext "-" remaining of 566 | (True, rest) -> if String.startsWith "]" rest 567 | then Ok (Plain '-', rest) -- `-` can be the last char in a set without being escaped???? 568 | else atom rest |> Result.andThen atomToCharOrErr 569 | |> Result.map (Tuple.mapFirst (Tuple.pair firstAtom >> Range)) 570 | 571 | _ -> Ok (Plain firstAtom, remaining) 572 | 573 | in case atom text of 574 | Ok (Plain char, rest) -> extractRange (char, rest) 575 | other -> other 576 | 577 | 578 | atomToCharOrErr : (CharsetAtom, String) -> ParseSubResult Char 579 | atomToCharOrErr (atom, rest) = case atom of 580 | Plain char -> Ok (char, rest) 581 | _ -> Err (Expected "Plain Character") 582 | 583 | 584 | -- in a charset, the dot char must be escaped, because a plain dot is just a dot and not anything but linebreak 585 | symbolizeTabLinebreakDot : Char -> Maybe Symbol 586 | symbolizeTabLinebreakDot token = case token of 587 | 't' -> Just TabChar 588 | 'n' -> Just LinebreakChar 589 | '.' -> Just NonLinebreakChar 590 | _ -> Nothing 591 | 592 | -- does not escape any brackets 593 | symbolizeLetterbased : Char -> Maybe Symbol 594 | symbolizeLetterbased token = case token of 595 | 'd' -> Just DigitChar 596 | 'D' -> Just NonDigitChar 597 | 's' -> Just WhitespaceChar 598 | 'S' -> Just NonWhitespaceChar 599 | 'w' -> Just WordChar 600 | 'W' -> Just NonWordChar 601 | 'b' -> Just WordBoundary 602 | 'B' -> Just NonWordBoundary 603 | _ -> Nothing 604 | 605 | 606 | 607 | 608 | parseAtomicChar : (Char -> Maybe Symbol) -> String -> ParseSubResult (CharsetAtom) 609 | parseAtomicChar escape text = 610 | let 611 | (isEscaped, charSubResult) = skipIfNext "\\" text |> Tuple.mapSecond parseSingleChar 612 | 613 | symbolOrElsePlainChar character = character |> escape 614 | |> Maybe.map Escaped |> Maybe.withDefault (Plain character) 615 | 616 | in if isEscaped 617 | then charSubResult |> Result.map (Tuple.mapFirst symbolOrElsePlainChar) 618 | else charSubResult |> Result.map (Tuple.mapFirst Plain) 619 | 620 | 621 | parseSingleChar : String -> ParseSubResult Char 622 | parseSingleChar string = String.uncons string |> okOrErr ExpectedMoreChars 623 | 624 | 625 | skipOrErr : String -> String -> ParseResult String 626 | skipOrErr symbol text = if String.startsWith symbol text 627 | then text |> String.dropLeft (String.length symbol) |> Ok 628 | else Err (Expected symbol) 629 | 630 | skipIfNext : String -> String -> (Bool, String) 631 | skipIfNext symbol text = if String.startsWith symbol text 632 | then (True, text |> String.dropLeft (String.length symbol)) 633 | else (False, text) 634 | 635 | okOrErr : ParseError -> Maybe v -> ParseResult v 636 | okOrErr error = Maybe.map Ok >> Maybe.withDefault (Err error) 637 | 638 | appendTo : List a -> a -> List a 639 | appendTo list element = list ++ [element] 640 | 641 | maybeOptions : (a -> Maybe b) -> (a -> Maybe b) -> a -> Maybe b 642 | maybeOptions first second value = case first value of 643 | Just result -> Just result 644 | Nothing -> second value 645 | 646 | 647 | 648 | 649 | oneOf a b v = a v || b v -------------------------------------------------------------------------------- /src/Update.elm: -------------------------------------------------------------------------------- 1 | module Update exposing (..) 2 | 3 | import AutoLayout 4 | import Model exposing (..) 5 | import Build exposing (buildRegex, compileRegex, cycles) 6 | import Vec2 exposing (Vec2) 7 | import Parse exposing (..) 8 | import Regex 9 | import IdMap exposing (IdMap) 10 | 11 | 12 | -- UPDATE 13 | 14 | type Message 15 | = SearchMessage SearchMessage 16 | | SetOutputLocked Bool 17 | | DragModeMessage DragModeMessage 18 | | UpdateNodeMessage NodeId Node 19 | | UpdateView ViewMessage 20 | | UpdateExampleText ExampleTextMessage 21 | | DeleteNode NodeId 22 | | DuplicateNode NodeId 23 | | AutoLayout Bool NodeId 24 | | DismissCyclesError -- TODO remove monolithic structure 25 | | Deselect 26 | | Undo 27 | | Redo 28 | | DoNothing 29 | 30 | type ExampleTextMessage 31 | = UpdateContents String 32 | | SetEditing Bool 33 | | UpdateMaxMatchLimit Int 34 | 35 | type SearchMessage 36 | = UpdateSearch String 37 | | FinishSearch SearchResult 38 | 39 | type SearchResult 40 | = InsertPrototype Node 41 | | ParseRegex String 42 | | InsertLiteral String 43 | | NoResult 44 | 45 | type ViewMessage 46 | = MagnifyView { amount: Float, focus: Vec2 } 47 | 48 | type DragModeMessage 49 | = StartNodeMove { node : NodeId, mouse : Vec2 } 50 | | StartViewMove { mouse: Vec2 } 51 | 52 | | StartPrepareEditingConnection { node: NodeId, mouse: Vec2 } 53 | | StartEditingConnection { nodeId: NodeId, node: Node, supplier: Maybe NodeId, mouse: Vec2 } 54 | | StartCreateConnection { supplier: NodeId, mouse: Vec2 } 55 | | RealizeConnection { nodeId: NodeId, newNode: Node, mouse: Vec2 } 56 | | FinishDrag 57 | 58 | | UpdateDrag { newMouse : Vec2 } 59 | 60 | maxUndoSteps = 100 61 | 62 | 63 | update : Message -> Model -> Model 64 | update message model = 65 | let 66 | coreModel = model.history.present 67 | advance newPresent = { model | history = advanceHistory model.history newPresent } 68 | advanceModel newModel = { newModel | history = advanceHistory model.history newModel.history.present } 69 | 70 | in case message of 71 | DoNothing -> model 72 | 73 | Undo -> { model | history = undo model.history } 74 | Redo -> { model | history = redo model.history } 75 | 76 | DismissCyclesError -> advance { coreModel | cyclesError = False } 77 | 78 | Deselect -> if coreModel.outputNode.locked 79 | then advance { coreModel | selectedNode = Nothing } 80 | else advance <| updateCache { coreModel | selectedNode = Nothing, outputNode = { locked = False, id = Nothing } } coreModel 81 | 82 | UpdateExampleText textMessage -> case textMessage of 83 | 84 | SetEditing enabled -> if not enabled 85 | -- update cache because text contents or match limit could have been changed 86 | then advance <| updateCache (enableEditingExampleText enabled coreModel) coreModel 87 | else advance <| enableEditingExampleText enabled coreModel 88 | 89 | UpdateContents text -> let old = coreModel.exampleText in 90 | advance { coreModel | exampleText = { old | contents = text } } 91 | 92 | UpdateMaxMatchLimit limit -> let old = coreModel.exampleText in 93 | advance { coreModel | exampleText = { old | maxMatches = limit } } 94 | 95 | 96 | UpdateView viewMessage -> 97 | if coreModel.exampleText.isEditing 98 | || IdMap.isEmpty coreModel.nodes 99 | 100 | then model 101 | 102 | else case viewMessage of 103 | MagnifyView { amount, focus } -> 104 | { model | view = updateView amount focus model.view } 105 | 106 | SetOutputLocked locked -> 107 | advance { coreModel | outputNode = { id = coreModel.outputNode.id, locked = locked } } 108 | 109 | UpdateNodeMessage id value -> 110 | advance <| updateCache { coreModel | nodes = updateNode coreModel.nodes id value } coreModel 111 | 112 | DuplicateNode id -> advance <| duplicateNode coreModel id 113 | DeleteNode id -> advanceModel <| deleteNode id model 114 | 115 | AutoLayout hard id -> advance <| { coreModel | nodes = AutoLayout.layout hard id coreModel.nodes } 116 | 117 | SearchMessage searchMessage -> 118 | case searchMessage of 119 | UpdateSearch query -> { model | search = Just query } 120 | FinishSearch result -> case result of 121 | NoResult -> { model | search = Nothing } 122 | InsertLiteral text -> advanceModel <| insertNode (LiteralNode text) model 123 | InsertPrototype prototype -> advanceModel <| (insertNode prototype (stopEditingExampleText model)) 124 | ParseRegex regex -> advanceModel <| 125 | (parseRegexNodes (stopEditingExampleText model) regex) 126 | 127 | DragModeMessage modeMessage -> 128 | case modeMessage of 129 | StartNodeMove { node, mouse } -> 130 | advanceModel <| startNodeMove mouse node model 131 | 132 | StartViewMove drag -> 133 | { model | dragMode = (Just <| MoveViewDrag drag) } 134 | 135 | -- update the subject node (disconnecting the input) 136 | -- and then start editing the connection of the old supplier 137 | -- TODO if current drag mode is retain, then reconnect to old node? 138 | StartEditingConnection { nodeId, node, supplier, mouse } -> 139 | advanceModel <| startEditingConnection nodeId node supplier mouse model 140 | 141 | 142 | StartCreateConnection { supplier, mouse } -> 143 | { model | dragMode = Just (CreateConnection { supplier = supplier, openEnd = mouse }) } 144 | 145 | StartPrepareEditingConnection { node, mouse } -> 146 | { model | dragMode = Just (PrepareEditingConnection { node = node, mouse = mouse }) } 147 | 148 | UpdateDrag { newMouse } -> 149 | case model.dragMode of 150 | Just (MoveNodeDrag { node, mouse }) -> 151 | moveNodeInModel newMouse mouse node model 152 | 153 | Just (MoveViewDrag { mouse }) -> 154 | moveViewInModel newMouse mouse model 155 | 156 | Just (CreateConnection { supplier }) -> 157 | { model | dragMode = Just <| CreateConnection { supplier = supplier, openEnd = newMouse } } 158 | 159 | _ -> model 160 | 161 | -- when a connection is established, update the drag mode of the model, 162 | -- but also already make the connection real 163 | RealizeConnection { nodeId, newNode, mouse } -> 164 | advanceModel <| realizeConnection model nodeId newNode mouse 165 | 166 | FinishDrag -> 167 | { model | dragMode = Nothing } 168 | 169 | 170 | 171 | advanceHistory history model = 172 | { past = List.take maxUndoSteps (history.present :: history.past), present = model, future = [] } 173 | 174 | undo history = case history.past of 175 | last :: older -> { past = older, present = last, future = history.present :: history.future } 176 | _ -> history 177 | 178 | redo history = case history.future of 179 | next :: newer -> { past = history.present :: history.past, present = next, future = newer } 180 | _ -> history 181 | 182 | 183 | updatePresent model presentUpdater = 184 | let history = model.history in 185 | { model | history = { history | present = presentUpdater history.present } } 186 | 187 | 188 | -- FIXME should not connect at all 189 | realizeConnection model nodeId newNode mouse = 190 | let 191 | newPresent present = updateCache 192 | { present | nodes = updateNode present.nodes nodeId newNode } 193 | present 194 | 195 | newDragMode = RetainPrototypedConnection 196 | { mouse = mouse, node = nodeId 197 | , previousNodeValue = IdMap.get nodeId model.history.present.nodes |> Maybe.map .node 198 | } 199 | 200 | newModel = updatePresent model newPresent 201 | 202 | in 203 | { newModel | dragMode = Just newDragMode } 204 | 205 | deleteNode: NodeId -> Model -> Model 206 | deleteNode nodeId model = 207 | let 208 | output = if model.history.present.outputNode.id == Just nodeId 209 | then Nothing else model.history.present.outputNode.id 210 | 211 | newNodeValues = model.history.present.nodes |> IdMap.remove nodeId 212 | |> IdMap.updateAllValues (\view -> { view | node = onNodeDeleted nodeId view.node }) 213 | 214 | newPresent present = updateCache 215 | { present 216 | | nodes = newNodeValues 217 | , outputNode = { id = output, locked = present.outputNode.locked } 218 | } 219 | present 220 | 221 | newModel = updatePresent model newPresent 222 | 223 | in 224 | { newModel | dragMode = Nothing } 225 | 226 | 227 | duplicateNode: CoreModel -> NodeId -> CoreModel 228 | duplicateNode model nodeId = 229 | let 230 | nodes = model.nodes 231 | node = IdMap.get nodeId nodes 232 | 233 | in case node of 234 | Nothing -> model 235 | Just original -> 236 | let 237 | position = Vec2.add original.position (Vec2 0 -28) 238 | clone = { original | position = position } 239 | 240 | in { model | nodes = IdMap.insertAnonymous clone nodes } 241 | 242 | insertNode: Node -> Model -> Model 243 | insertNode node model = 244 | let 245 | position = Vec2.inverseTransform (Vec2 800 400) (viewTransform model.view) 246 | newPresent present = { present | nodes = IdMap.insertAnonymous (NodeView position node) present.nodes } 247 | newModel = updatePresent model newPresent 248 | in { newModel | search = Nothing } 249 | 250 | parseRegexNodes: Model -> String -> Model 251 | parseRegexNodes model regex = 252 | let 253 | history = model.history 254 | coreModel = history.present 255 | 256 | position = Vec2.inverseTransform (Vec2 1000 400) (viewTransform model.view) 257 | resultNodes = addParsedRegexNode position coreModel.nodes regex 258 | 259 | -- select the generated result and autolayout 260 | resultHistory resultNodeId nodes = 261 | { history | present = selectNode resultNodeId { coreModel | nodes = AutoLayout.layout True resultNodeId nodes } } 262 | 263 | -- clear the search 264 | resultModel (resultNodeId, nodes) = 265 | { model | history = resultHistory resultNodeId nodes, search = Nothing } 266 | 267 | in resultNodes |> Result.map resultModel |> Result.withDefault model 268 | 269 | 270 | startNodeMove: Vec2 -> NodeId -> Model -> Model 271 | startNodeMove mouse node model = 272 | let 273 | history = model.history 274 | present = history.present 275 | 276 | in { model 277 | | dragMode = Just (MoveNodeDrag { node = node, mouse = mouse }) 278 | , history = { history | present = selectNode node present } 279 | } 280 | 281 | startEditingConnection : NodeId -> Node -> Maybe NodeId -> Vec2 -> Model -> Model 282 | startEditingConnection nodeId node currentSupplier mouse model = 283 | let 284 | newPresent present = updateCache 285 | { present | nodes = updateNode present.nodes nodeId node } 286 | present 287 | 288 | newModel = updatePresent model newPresent 289 | 290 | updateIt oldSupplier = 291 | { newModel | dragMode = Just (CreateConnection { supplier = oldSupplier, openEnd = mouse }) } 292 | 293 | in currentSupplier |> Maybe.map updateIt |> Maybe.withDefault model 294 | 295 | 296 | selectNode: NodeId -> CoreModel -> CoreModel 297 | selectNode node model = 298 | let 299 | safeModel = { model | selectedNode = Just node } 300 | 301 | possiblyInvalidModel = { safeModel 302 | | outputNode = if not model.outputNode.locked || model.outputNode.id == Nothing 303 | then { id = Just node, locked = model.outputNode.locked } 304 | else model.outputNode 305 | } 306 | 307 | in if model.outputNode.id /= possiblyInvalidModel.outputNode.id 308 | -- only update cache if node really changed 309 | then updateCache possiblyInvalidModel safeModel else possiblyInvalidModel 310 | 311 | 312 | 313 | stopEditingExampleText: Model -> Model 314 | stopEditingExampleText model = 315 | updatePresent model (enableEditingExampleText False) 316 | 317 | enableEditingExampleText: Bool -> CoreModel -> CoreModel 318 | enableEditingExampleText enabled model = 319 | let old = model.exampleText in 320 | { model | exampleText = { old | isEditing = enabled } } 321 | 322 | 323 | updateNode : Nodes -> NodeId -> Node -> Nodes 324 | updateNode nodes id newNode = 325 | nodes |> IdMap.update id (\nodeView -> { nodeView | node = newNode }) 326 | 327 | 328 | moveNodeInModel: Vec2 -> Vec2 -> NodeId -> Model -> Model 329 | moveNodeInModel newMouse mouse node model = 330 | let 331 | delta = Vec2.sub newMouse mouse 332 | newPresent present = { present |nodes = moveNode model.view present.nodes node delta } 333 | newModel = updatePresent model newPresent 334 | 335 | in { newModel | dragMode = Just (MoveNodeDrag { node = node, mouse = newMouse }) } 336 | 337 | moveViewInModel: Vec2 -> Vec2 -> Model -> Model 338 | moveViewInModel newMouse mouse model = 339 | let 340 | view = model.view 341 | delta = Vec2.sub newMouse mouse 342 | 343 | in { model | dragMode = Just <| MoveViewDrag { mouse = newMouse } 344 | , view = { view | offset = Vec2.add view.offset delta } 345 | } 346 | 347 | moveNode : View -> Nodes -> NodeId -> Vec2 -> Nodes 348 | moveNode view nodes nodeId movement = 349 | let 350 | transform = viewTransform view 351 | viewMovement = Vec2.scale (1 / transform.scale) movement 352 | updateNodePosition nodeView = { nodeView | position = Vec2.add nodeView.position viewMovement } 353 | in IdMap.update nodeId updateNodePosition nodes 354 | 355 | 356 | updateView amount focus oldView = 357 | let 358 | magnification = oldView.magnification + amount * 0.009 -- * 0.4 359 | transform = viewTransform { magnification = magnification, offset = oldView.offset } 360 | 361 | oldTransform = viewTransform oldView 362 | deltaScale = transform.scale / oldTransform.scale 363 | 364 | newView = if transform.scale < 0.1 || transform.scale > 16 then oldView else 365 | { magnification = magnification 366 | , offset = 367 | { x = (oldView.offset.x - focus.x) * deltaScale + focus.x 368 | , y = (oldView.offset.y - focus.y) * deltaScale + focus.y 369 | } 370 | } 371 | 372 | in newView 373 | 374 | 375 | -- TODO ( updateUrl "regex result" ) 376 | updateCache : CoreModel -> CoreModel -> CoreModel 377 | updateCache model fallback = 378 | let 379 | example = model.exampleText 380 | regex = model.outputNode.id |> Maybe.map (buildRegex model.nodes) 381 | 382 | in if regex == Just (Err cycles) 383 | then { fallback | cyclesError = True } else 384 | let 385 | multiple = regex |> Maybe.map 386 | (Result.map (.flags >> .multiple) >> Result.withDefault False) 387 | |> Maybe.withDefault False 388 | 389 | compiled = regex |> Maybe.andThen (Result.map compileRegex >> Result.map Just >> Result.withDefault Nothing) 390 | newExample = { example | cachedMatches = Maybe.map (extractMatches multiple example.maxMatches example.contents) compiled } 391 | 392 | in { model | exampleText = newExample, cyclesError = False, cachedRegex = regex } -- hide error on success 393 | 394 | 395 | extractMatches : Bool -> Int -> String -> Regex.Regex -> List (String, String) 396 | extractMatches multiple maxMatches text regex = 397 | let 398 | matches = Regex.findAtMost (if multiple then maxMatches else 1) regex text 399 | 400 | extractMatch match (textStartIndex, extractedMatches) = 401 | let 402 | textBeforeMatch = String.slice textStartIndex match.index text 403 | indexAfterMatch = match.index + String.length match.match 404 | in (indexAfterMatch, extractedMatches ++ [(textBeforeMatch, match.match)]) 405 | 406 | extract rawMatches = 407 | -- use foldr in order to utilize various optimizations 408 | let (indexAfterLastMatch, extractedMatches) = List.foldl extractMatch (0, []) rawMatches 409 | 410 | in if List.length matches == maxMatches 411 | -- do not append unprocessed text 412 | -- (if maximum matches were processed, any text after the last match has not been processed) 413 | then extractedMatches 414 | 415 | -- else, also add all the text after the last match 416 | else extractedMatches ++ [(String.slice indexAfterLastMatch (String.length text) text, "")] 417 | 418 | 419 | simplify = List.foldr simplifyMatch [] 420 | 421 | simplifyMatch (before, match) alreadySimplified = case alreadySimplified of 422 | -- if text between this and successor match is empty, merge them into a single match 423 | ("", immediateSuccessor) :: moreRest -> (before, match ++ immediateSuccessor) :: moreRest 424 | 425 | -- just append otherwise 426 | other -> (before, match) :: other 427 | 428 | 429 | -- replace spaces by (hair-space ++ dot ++ hair-space) to visualize whitespace 430 | -- (separate function to avoid recursion stack overflow) 431 | visualizeMatch match = String.replace " " "\u{200B}␣\u{200B}" match -- " " "\u{200A}·\u{200A}" match 432 | visualize matchList = List.map (Tuple.mapSecond visualizeMatch) matchList 433 | 434 | in matches |> extract |> simplify |> visualize 435 | 436 | -------------------------------------------------------------------------------- /src/Vec2.elm: -------------------------------------------------------------------------------- 1 | module Vec2 exposing (..) 2 | 3 | 4 | type alias Vec2 = 5 | { x : Float 6 | , y : Float 7 | } 8 | 9 | 10 | zero = Vec2 0 0 11 | 12 | add : Vec2 -> Vec2 -> Vec2 13 | add a b = Vec2 (a.x + b.x) (a.y + b.y) 14 | 15 | sub : Vec2 -> Vec2 -> Vec2 16 | sub a b = Vec2 (a.x - b.x) (a.y - b.y) 17 | 18 | scale : Float -> Vec2 -> Vec2 19 | scale s v = Vec2 (v.x * s) (v.y * s) 20 | 21 | fromTuple : (Float, Float) -> Vec2 22 | fromTuple value = Vec2 (Tuple.first value) (Tuple.second value) 23 | 24 | inverseTransform : Vec2 -> { scale: Float, translate: Vec2 } -> Vec2 25 | inverseTransform value transformation = 26 | sub value transformation.translate |> scale (1 / transformation.scale) 27 | 28 | transform : Vec2 -> { scale: Float, translate: Vec2 } -> Vec2 29 | transform value transformation = 30 | scale transformation.scale value |> add transformation.translate 31 | 32 | squareLength : Vec2 -> Float 33 | squareLength value = value.x * value.x + value.y * value.y 34 | 35 | length : Vec2 -> Float 36 | length value = sqrt (squareLength value) 37 | 38 | ray magnitude direction origin = Vec2 39 | (origin.x + magnitude * direction.x) 40 | (origin.y + magnitude * direction.y) -------------------------------------------------------------------------------- /src/View.elm: -------------------------------------------------------------------------------- 1 | module View exposing (..) 2 | 3 | import Array exposing (Array) 4 | import Html exposing (..) 5 | import Html.Lazy exposing (lazy) 6 | import Html.Attributes exposing (..) 7 | import Html.Events exposing (onInput, onBlur, onFocus) 8 | import Dict exposing (Dict) 9 | import Html.Events.Extra.Mouse as Mouse 10 | import Html.Events.Extra.Wheel as Wheel 11 | import IdMap 12 | import Svg exposing (Svg, svg, line, g) 13 | import Svg.Attributes exposing (x1, x2, y1, y2) 14 | import Regex 15 | import Json.Decode 16 | 17 | import Vec2 exposing (Vec2) 18 | import Model exposing (..) 19 | import Build exposing (..) 20 | import Update exposing (..) 21 | 22 | 23 | 24 | type alias NodeView = 25 | { node: Html Message 26 | , connections: List (Svg Message) 27 | } 28 | 29 | 30 | 31 | -- VIEW 32 | 33 | 34 | view : Model -> Html Message 35 | view untrackedModel = 36 | let 37 | trackedModel = untrackedModel.history.present 38 | expressionResult = trackedModel.cachedRegex |> Maybe.map (Result.map constructRegexLiteral) 39 | 40 | (moveDragging, connectDragId, mousePosition) = case untrackedModel.dragMode of 41 | Just (MoveNodeDrag { mouse }) -> (True, Nothing, mouse) 42 | Just (CreateConnection { supplier, openEnd }) -> (False, Just supplier, openEnd) 43 | _ -> (False, Nothing, Vec2 0 0) 44 | 45 | connectDragging = connectDragId /= Nothing 46 | 47 | nodeViews = (List.map (viewNode untrackedModel.dragMode trackedModel.selectedNode trackedModel.outputNode.id trackedModel.nodes) (IdMap.toList trackedModel.nodes)) 48 | 49 | connections = flattenList (List.map .connections nodeViews) 50 | 51 | startViewMove event = 52 | if event.button == Mouse.MiddleButton 53 | then DragModeMessage <| StartViewMove { mouse = Vec2.fromTuple event.clientPos } 54 | else Deselect 55 | 56 | in div 57 | [ conservativeOnMouse "mousemove" (\event -> DragModeMessage -- do not prevent default (which is text selection) 58 | (UpdateDrag { newMouse = Vec2.fromTuple event.clientPos }) 59 | ) 60 | 61 | , Mouse.onUp (always <| DragModeMessage FinishDrag) 62 | , Mouse.onLeave (always <| DragModeMessage FinishDrag) 63 | 64 | 65 | , id "main" 66 | , classes "" [(moveDragging, "move-dragging"), (connectDragging, "connect-dragging"), (trackedModel.exampleText.isEditing, "editing-example-text")] 67 | ] 68 | 69 | [ lazy viewExampleText trackedModel.exampleText 70 | 71 | , svg [ id "connection-graph" ] 72 | [ g [ magnifyAndOffsetSVG untrackedModel.view ] 73 | (if connectDragging then connections ++ [ viewConnectDrag untrackedModel.view trackedModel.nodes connectDragId mousePosition ] else connections) 74 | ] 75 | 76 | , div 77 | 78 | ( 79 | [ id "node-graph" 80 | , (Mouse.onWithOptions "mousedown" { stopPropagation = False, preventDefault = False } startViewMove) -- do not prevent input blur on click 81 | , Wheel.onWheel wheel 82 | , preventContextMenu (always DoNothing) 83 | ] 84 | ) 85 | 86 | [ div [ class "transform-wrapper", magnifyAndOffsetHTML untrackedModel.view ] 87 | (List.map .node nodeViews) 88 | ] 89 | 90 | , div [ id "overlay" ] 91 | [ nav [] 92 | [ header [] 93 | [ img [ src "html/img/logo.svg" ] [] -- TODO also link image to "reset application" 94 | , h1 [] 95 | [ a 96 | [ href "https://johannesvollmer.github.io/regex-nodes/" 97 | , title "Restart Application" 98 | ] 99 | [ text "Regex Nodes" ] 100 | ] 101 | , a 102 | [ href "https://johannesvollmer.github.io/2019/announcing-regex-nodes/", target "_blank", rel "noopener noreferrer" 103 | , title "johannesvollmer.github.io/announcing-regex-nodes" 104 | ] 105 | [ text " About " ] 106 | , a 107 | [ href "https://johannesvollmer.github.io/2019/announcing-regex-nodes/#functionality-reference", target "_blank", rel "noopener noreferrer" 108 | , title "johannesvollmer.github.io/functionality-reference" 109 | ] 110 | [ text " Help " ] 111 | , a 112 | [ href "https://github.com/johannesvollmer/regex-nodes", target "_blank", rel "noopener noreferrer" 113 | , title "github.com/johannesvollmer/regex-nodes" 114 | ] 115 | [ text " Github " ] 116 | ] 117 | 118 | , div [ id "example-options" ] 119 | [ div 120 | [ id "match-limit" 121 | , title ("Display no more than " ++ String.fromInt trackedModel.exampleText.maxMatches 122 | ++ " Matches from the example text, in order to perserve responsivenes" 123 | ) 124 | ] 125 | [ text "Example Match Limit" 126 | , viewPositiveIntInput trackedModel.exampleText.maxMatches (UpdateExampleText << UpdateMaxMatchLimit) 127 | ] 128 | 129 | , div 130 | [ id "edit-example", class "button" 131 | , checked trackedModel.exampleText.isEditing 132 | , Mouse.onClick (always <| UpdateExampleText <| SetEditing <| not trackedModel.exampleText.isEditing) 133 | , title "Edit the Text which is displayed in the background" 134 | ] 135 | 136 | [ text "Edit Example" ] 137 | ] 138 | ] 139 | 140 | , div [ id "search" ] 141 | [ viewSearchBar untrackedModel.search 142 | , viewSearchResults untrackedModel.search 143 | ] 144 | 145 | , div [ id "history" ] 146 | [ div 147 | [ id "undo", title "Undo the last action" 148 | , classes "button" [(List.isEmpty untrackedModel.history.past, "disabled")] 149 | , Mouse.onClick (always Undo) 150 | ] 151 | [ img [ src "html/img/arrow-left.svg" ] [] ] 152 | 153 | , div 154 | [ id "redo", title "Undo the last action" 155 | , classes "button" [(List.isEmpty untrackedModel.history.future, "disabled")] 156 | , Mouse.onClick (always Redo) 157 | ] 158 | [ img [ src "html/img/arrow-left.svg" ] [] ] 159 | ] 160 | 161 | , div [ id "expression-result", classes "" [(expressionResult == Nothing, "no")] ] 162 | [ code [] 163 | [ span [ id "declaration" ] [ text "const regex = " ] 164 | , text (expressionResult |> Maybe.withDefault (Ok "/(nothing)/") |> unwrapResult) 165 | ] 166 | 167 | , div 168 | [ id "lock", classes "button" [(trackedModel.outputNode.locked, "checked")] 169 | , Mouse.onClick (always <| SetOutputLocked <| not trackedModel.outputNode.locked) 170 | , title "Always show the regex of the selected Node" 171 | ] 172 | [ lockSvg ] 173 | ] 174 | ] 175 | 176 | {-, div 177 | [ id "confirm-deletion" 178 | , classes "alert" [(model.confirmDeletion /= Nothing, "show")] 179 | , Mouse.onClick <| always <| ConfirmDeleteNode False 180 | , stopMousePropagation "wheel" 181 | ] 182 | 183 | [ div [ class "dialog-box" ] 184 | [ p [] [ text ("Delete that node?") ] 185 | , div [ class "options" ] 186 | [ div 187 | [ class "confirm", class "button" 188 | , onMouseWithStopPropagation "click" (always <| ConfirmDeleteNode True) 189 | ] 190 | [ text "Delete" ] 191 | 192 | , div [ class "cancel", class "button" ] [ text "Cancel" ] 193 | ] 194 | 195 | ] 196 | ]-} 197 | 198 | , div 199 | [ id "cycles-detected" 200 | , classes "notification button" [(trackedModel.cyclesError, "show")] 201 | , Mouse.onClick <| always <| DismissCyclesError 202 | ] 203 | 204 | [ text ("Some actions cannot be performed due to cycles in the node graph.") 205 | , div [] [ text ("Make sure there are no cyclic connections. Click to dismiss.") ] 206 | -- TODO or maybe the graph is just too complex, enable increasing limit in ui 207 | ] 208 | ] 209 | 210 | 211 | 212 | wheel event = 213 | { amount = case event.deltaMode of 214 | Wheel.DeltaPixel -> -event.deltaY 215 | Wheel.DeltaLine -> -event.deltaY * 40 216 | Wheel.DeltaPage -> -event.deltaY * 1000 217 | 218 | , focus = (Vec2.fromTuple event.mouseEvent.clientPos) 219 | } 220 | 221 | |> MagnifyView |> UpdateView 222 | 223 | unwrapResult result = case result of 224 | Ok a -> a 225 | Err a -> a 226 | 227 | lockSvg = 228 | Svg.svg 229 | [ Svg.Attributes.width "50", Svg.Attributes.height "50", Svg.Attributes.viewBox "0 0 10 10" ] 230 | [ Svg.path [ Svg.Attributes.id "bracket", Svg.Attributes.d "M 3,3 v -1.5 c 0,-2 4,-2 4,0 v 4" ] [] 231 | , Svg.rect 232 | [ Svg.Attributes.x "2", Svg.Attributes.y "5" 233 | , Svg.Attributes.width "6", Svg.Attributes.height "4" 234 | , Svg.Attributes.id "body" 235 | ] [ ] 236 | ] 237 | 238 | preventContextMenu handler = Mouse.onWithOptions "contextmenu" 239 | { preventDefault = True, stopPropagation = False } 240 | handler 241 | 242 | conservativeOnMouse tag handler = Mouse.onWithOptions tag 243 | { preventDefault = False, stopPropagation = False } 244 | handler 245 | 246 | viewSearchResults search = 247 | div 248 | [ id "results" 249 | , stopMousePropagation "wheel" 250 | ] 251 | (Maybe.withDefault [] (Maybe.map viewSearch search) ) 252 | 253 | viewSearchBar search = input 254 | [ placeholder (if search == Nothing then "Add Nodes" else "Search Nodes or Enter A Regular Expression") 255 | , type_ "text" 256 | , value (Maybe.withDefault "" search) 257 | , onFocus (SearchMessage (UpdateSearch "")) 258 | , onInput (\text -> SearchMessage (UpdateSearch text)) -- TODO if enter, submit first 259 | , onBlur (SearchMessage (FinishSearch NoResult)) 260 | ] [] 261 | 262 | 263 | 264 | viewSearch : String -> List (Html Message) 265 | viewSearch query = 266 | let 267 | isEmpty = String.isEmpty query 268 | lowercaseQuery = String.toLower query 269 | regex = Maybe.withDefault Regex.never (Regex.fromStringWith { caseInsensitive = True, multiline = False } query) 270 | 271 | test name = isEmpty 272 | || String.contains lowercaseQuery (String.toLower name) 273 | || List.all (\word -> String.contains word (String.toLower name)) (String.words lowercaseQuery) 274 | || (Regex.contains regex name) 275 | 276 | matches prototype = test prototype.name 277 | 278 | render prototype = div 279 | [ class "button" 280 | , Mouse.onWithOptions 281 | "mousedown" 282 | { stopPropagation = False, preventDefault = False } -- do not prevent blurring the textbox on selecting a result 283 | (\_ -> SearchMessage (FinishSearch (InsertPrototype prototype.node))) 284 | ] 285 | 286 | [ p [ class "name" ] [ text prototype.name ] 287 | , p [ class "description" ] [ text prototype.description ] 288 | ] 289 | 290 | asRegex = div 291 | [ class "button" 292 | , Mouse.onWithOptions 293 | "mousedown" 294 | { stopPropagation = False, preventDefault = False } -- do not prevent blurring the textbox on selecting a result 295 | (\_ -> SearchMessage (FinishSearch (ParseRegex query))) 296 | ] 297 | [ text "Insert regular expression `" 298 | , code [ ] [ text (insertWhitePlaceholder query) ] 299 | , text "` as Nodes" 300 | , p [ class "description" ] [ text "Add that regular expression by converting it to a network of Nodes" ] 301 | ] 302 | 303 | asLiteral = div 304 | [ class "button" 305 | , Mouse.onWithOptions 306 | "mousedown" 307 | { stopPropagation = False, preventDefault = False } -- do not prevent blurring the textbox on selecting a result 308 | (\_ -> SearchMessage (FinishSearch (InsertLiteral query))) 309 | ] 310 | [ text "Insert literal `" 311 | , code [][ text(insertWhitePlaceholder query) ] 312 | , text "` as Node " 313 | , p [ class "description" ] [ text ("Add a Node which matches exactly `" ++ query ++ "` and nothing else") ] 314 | ] 315 | 316 | results = (prototypes |> List.filter matches |> List.map render) 317 | 318 | in if isEmpty then results 319 | else asRegex :: (asLiteral :: results) 320 | 321 | 322 | viewExampleText example = 323 | if example.isEditing 324 | then textarea [ id "example-text", onInput (UpdateExampleText << UpdateContents) ] 325 | [ text example.contents ] 326 | 327 | else 328 | let 329 | texts = example.cachedMatches |> Maybe.map viewExampleTexts 330 | |> Maybe.withDefault [ text example.contents ] 331 | 332 | in div [ id "example-text" ] texts 333 | 334 | 335 | viewExampleTexts : List (String, String) -> List (Html Message) 336 | viewExampleTexts matches = 337 | let 338 | render matchPair = 339 | [ Html.text (Tuple.first matchPair) 340 | , span [ class "match" ] [ Html.text (Tuple.second matchPair) ] 341 | ] 342 | 343 | in matches |> List.concatMap render 344 | 345 | 346 | viewNode : Maybe DragMode -> Maybe NodeId -> Maybe NodeId -> Nodes -> (NodeId, Model.NodeView) -> NodeView 347 | viewNode dragMode selectedNode outputNode nodes (nodeId, nodeView) = 348 | let props = nodeProperties nodeView.node in 349 | NodeView (viewNodeContent nodes dragMode selectedNode outputNode nodeId props nodeView) (viewNodeConnections nodes props nodeView) 350 | 351 | 352 | viewNodeConnections : Nodes -> List Property -> Model.NodeView -> List (Svg Message) 353 | viewNodeConnections nodes props nodeView = 354 | let 355 | connectionLine from width to index = Svg.path 356 | [ Svg.Attributes.class "connection" 357 | , bezierSvgConnectionpath 358 | (Vec2 to.x (to.y + ((toFloat index) + 0.5) * propertyHeight)) 359 | (Vec2 (from.x + width) (from.y + 0.5 * propertyHeight)) 360 | ] 361 | [] 362 | 363 | connect : NodeId -> Model.NodeView -> (Int -> Maybe (Svg Message)) 364 | connect supplierId node index = 365 | let 366 | supplier = IdMap.get supplierId nodes 367 | viewSupplier supplierNodeView = 368 | connectionLine supplierNodeView.position (nodeWidth supplierNodeView.node) node.position index 369 | 370 | in Maybe.map viewSupplier supplier 371 | 372 | viewInputConnection : Property -> List (Int -> Maybe (Svg Message)) 373 | viewInputConnection property = case property.contents of 374 | ConnectingProperty (Just supplier) _ -> 375 | [ connect supplier nodeView ] 376 | 377 | ConnectingProperties _ suppliers _ -> 378 | suppliers |> Array.toList |> List.map (\supplier -> connect supplier nodeView) 379 | 380 | _ -> [ always Nothing ] 381 | 382 | -- TODO use lazy html! 383 | flattened = props |> List.map viewInputConnection |> flattenList 384 | indexed = flattened |> List.indexedMap (\index at -> at index) 385 | filtered = List.filterMap identity indexed 386 | 387 | in filtered 388 | 389 | 390 | viewConnectDrag : View -> Nodes -> Maybe NodeId -> Vec2 -> Html Message 391 | viewConnectDrag viewTransformation nodes dragId mouse = 392 | let 393 | node = Maybe.andThen (\id -> IdMap.get id nodes) dragId 394 | nodePosition = Maybe.map (.position) node |> Maybe.withDefault Vec2.zero 395 | 396 | nodeAnchor = Vec2 397 | (nodePosition.x + (Maybe.map (.node >> nodeWidth) node |> Maybe.withDefault 0)) 398 | (nodePosition.y + 0.5 * 25.0) 399 | 400 | transform = viewTransform viewTransformation 401 | transformedMouse = Vec2.inverseTransform mouse transform 402 | 403 | in Svg.path 404 | [ Svg.Attributes.class "prototype connection" 405 | , bezierSvgConnectionpath transformedMouse nodeAnchor 406 | ] 407 | [] 408 | 409 | 410 | bezierSvgConnectionpath from to = 411 | let 412 | tangentX1 = from.x - abs(to.x - from.x) * 0.4 413 | tangentX2 = to.x + abs(to.x - from.x) * 0.4 414 | 415 | in svgConnectionPath 416 | from (Vec2 tangentX1 from.y) (Vec2 tangentX2 to.y) to 417 | 418 | svgConnectionPath from fromTangent toTangent to = 419 | let 420 | vec2ToString vec = String.fromFloat vec.x ++ "," ++ String.fromFloat vec.y ++ " " 421 | path = "M" ++ vec2ToString from ++ "C" ++ vec2ToString fromTangent 422 | ++ vec2ToString toTangent ++ vec2ToString to 423 | in Svg.Attributes.d path 424 | 425 | 426 | hasDragConnectionPrototype dragMode nodeId = case dragMode of 427 | Just (CreateConnection { supplier }) -> nodeId == supplier 428 | _ -> False 429 | 430 | -- TODO use lazy html! 431 | viewNodeContent : Nodes -> Maybe DragMode -> Maybe NodeId -> Maybe NodeId -> NodeId -> List Property -> Model.NodeView -> Html Message 432 | viewNodeContent nodes dragMode selectedNode outputNode nodeId props nodeView = 433 | let 434 | contentWidth = (nodeWidth nodeView.node |> String.fromFloat) ++ "px" 435 | 436 | mayDragConnect = case dragMode of 437 | Just (PrepareEditingConnection { node }) -> nodeId == node 438 | Just (RetainPrototypedConnection { node }) -> nodeId == node 439 | _ -> False 440 | 441 | -- equivalent to on right mouse down on MacOS 442 | onContextMenu event = 443 | if dragMode == Nothing then -- only do that on MacOs (windows mouse up will fail this check) 444 | DragModeMessage (StartPrepareEditingConnection { node = nodeId, mouse = Vec2.fromTuple event.clientPos }) 445 | 446 | else DoNothing 447 | 448 | onMouseDownAndStopPropagation event = 449 | if event.button == Mouse.SecondButton then 450 | { message = DragModeMessage (StartPrepareEditingConnection { node = nodeId, mouse = Vec2.fromTuple event.clientPos }) 451 | , stopPropagation = True 452 | , preventDefault = True 453 | } 454 | 455 | else if event.button == Mouse.MainButton then 456 | { message = DragModeMessage (StartNodeMove { node = nodeId, mouse = Vec2.fromTuple event.clientPos }) 457 | , stopPropagation = True 458 | , preventDefault = True 459 | } 460 | 461 | -- do not stop event propagation on middle mouse down 462 | else { message = DoNothing, stopPropagation = False, preventDefault = False } 463 | 464 | -- TODO dry 465 | 466 | duplicateAndStopPropagation event = 467 | if event.button == Mouse.MainButton 468 | then (DuplicateNode nodeId, True) 469 | else (DoNothing, False) -- do not stop event propagation on non-primary mouse down 470 | 471 | deleteAndStopPropagation event = 472 | if event.button == Mouse.MainButton 473 | then (DeleteNode nodeId, True) 474 | else (DoNothing, False) -- do not stop event propagation on non-primary mouse down 475 | 476 | autolayoutAndStopPropagation event = 477 | if event.button == Mouse.MainButton 478 | then (AutoLayout False nodeId, True) 479 | else (DoNothing, False) -- do not stop event propagation on non-primary mouse down 480 | 481 | mayStopPropagation : String -> (Mouse.Event -> (Message, Bool)) -> Attribute Message 482 | mayStopPropagation tag handler = Html.Events.stopPropagationOn 483 | tag (Mouse.eventDecoder |> Json.Decode.map handler) 484 | 485 | 486 | preventDefaultAndMayStopPropagation : String -> 487 | (Mouse.Event -> { message: Message, preventDefault: Bool, stopPropagation: Bool }) 488 | -> Attribute Message 489 | 490 | preventDefaultAndMayStopPropagation tag handler = Html.Events.custom 491 | tag (Mouse.eventDecoder |> Json.Decode.map handler) 492 | 493 | in div 494 | [ style "width" contentWidth 495 | , translateHTML nodeView.position 496 | , classes "graph-node" 497 | [ (hasDragConnectionPrototype dragMode nodeId, "connecting") 498 | , (outputNode == Just nodeId, "output") 499 | , (selectedNode == Just nodeId, "selected") 500 | , (mayDragConnect, "may-drag-connect") 501 | ] 502 | ] 503 | 504 | [ div 505 | [ class "properties" 506 | , preventDefaultAndMayStopPropagation "mousedown" onMouseDownAndStopPropagation -- always prevent default (to prevent native drag)? 507 | , preventContextMenu onContextMenu 508 | ] 509 | (viewProperties nodes nodeId dragMode props) 510 | 511 | , div 512 | [ class "menu" ] 513 | [ div 514 | [ mayStopPropagation "mousedown" autolayoutAndStopPropagation -- must be mousedown because click would be triggered after deselect on mouse down 515 | , class "autolayout button" 516 | , title "Automatically layout all inputs of this node" 517 | ] 518 | [ img [ src "html/img/tidy.svg" ] [] ] 519 | , div 520 | [ mayStopPropagation "mousedown" duplicateAndStopPropagation -- must be mousedown because click would be triggered after deselect on mouse down 521 | , class "duplicate button" 522 | , title "Duplicate this Node" 523 | ] 524 | [ img [ src "html/img/copy.svg" ] [] ] 525 | 526 | , div 527 | [ mayStopPropagation "mousedown" deleteAndStopPropagation -- must be mousedown because click would be triggered after deselect on mouse down 528 | , class "delete button" 529 | , title "Delete this Node" 530 | ] 531 | [ img [ src "html/img/bin.svg" ] [] ] 532 | ] 533 | 534 | ] 535 | 536 | 537 | viewProperties : Nodes -> NodeId -> Maybe DragMode -> List Property -> List (Html Message) 538 | viewProperties nodes nodeId dragMode props = 539 | let 540 | 541 | mayStartConnectDrag = case dragMode of 542 | Just (PrepareEditingConnection { node }) -> nodeId == node 543 | Just (RetainPrototypedConnection { node }) -> nodeId == node 544 | _ -> False 545 | 546 | enableDisconnect = case dragMode of 547 | Just (PrepareEditingConnection _) -> True 548 | Just (RetainPrototypedConnection _) -> True 549 | _ -> False 550 | 551 | onLeave : Maybe { supplier: Maybe NodeId, onChange: OnChange (Maybe NodeId) } -> Bool -> Mouse.Event -> Message 552 | onLeave input output event = DragModeMessage ( 553 | case dragMode of 554 | -- Just (RetainPrototypedConnection { mouse }) -> TODO 555 | 556 | Just (PrepareEditingConnection { mouse }) -> 557 | case input of 558 | Just { supplier, onChange } -> 559 | if (Tuple.first event.clientPos) < mouse.x && supplier /= Nothing then 560 | StartEditingConnection { supplier = supplier, node = onChange Nothing, nodeId = nodeId, mouse = Vec2.fromTuple event.clientPos } 561 | 562 | else if output then StartCreateConnection { supplier = nodeId, mouse = Vec2.fromTuple event.clientPos } 563 | else FinishDrag 564 | 565 | Nothing -> if output then StartCreateConnection { supplier = nodeId, mouse = Vec2.fromTuple event.clientPos } 566 | else FinishDrag 567 | 568 | _ -> UpdateDrag { newMouse = Vec2.fromTuple event.clientPos } 569 | ) 570 | 571 | leftConnector active = div 572 | [ (classes "left connector" [(not active, "inactive")]) ] 573 | [] 574 | 575 | rightConnector active = div 576 | [ (classes "right connector" [(not active, "inactive")]) ] 577 | [] 578 | 579 | 580 | propertyHTML: List (Attribute Message) -> Html Message -> String -> String -> Bool -> Html Message -> Html Message -> Html Message 581 | propertyHTML attributes directInput name description connectableInput left right = div 582 | ((classes "property" [(connectableInput, "connectable-input")]) :: (title description :: attributes)) 583 | 584 | [ left 585 | , span [ class "title" ] [ text name ] 586 | , directInput 587 | , right 588 | ] 589 | 590 | updateNode = UpdateNodeMessage nodeId 591 | 592 | simpleInputProperty property directInput = propertyHTML 593 | [ Mouse.onLeave (onLeave Nothing property.connectOutput) ] directInput property.name property.description False (leftConnector False) (rightConnector property.connectOutput) 594 | 595 | connectInputProperty property currentSupplier onChange maybePreviewRegex = 596 | let 597 | connectOnEnter supplier event = 598 | DragModeMessage (RealizeConnection { mouse = Vec2.fromTuple event.clientPos, nodeId = nodeId, newNode = onChange (Just supplier) }) 599 | 600 | onEnter = case dragMode of 601 | Just (CreateConnection { supplier }) -> 602 | if supplier == nodeId then [] else -- TODO check for real cycles 603 | [ Mouse.onEnter (connectOnEnter supplier) ] 604 | 605 | _ -> [] 606 | 607 | onLeaveHandlers = if enableDisconnect && mayStartConnectDrag 608 | then [ Mouse.onLeave (onLeave (Just { supplier = currentSupplier, onChange = onChange }) property.connectOutput) ] else [] 609 | 610 | left = leftConnector True 611 | 612 | preview = case maybePreviewRegex of 613 | Nothing -> div[][] 614 | Just regex -> div[ class "regex-preview" ][ text regex ] 615 | 616 | in propertyHTML (onEnter ++ onLeaveHandlers) preview property.name property.description True left (rightConnector property.connectOutput) 617 | 618 | singleProperty property = case property.contents of 619 | BoolProperty value onChange -> [ simpleInputProperty property (viewBoolInput value (onChange (not value) |> updateNode)) ] 620 | CharsProperty chars onChange -> [ simpleInputProperty property (viewCharsInput chars (onChange >> updateNode)) ] 621 | CharProperty char onChange -> [ simpleInputProperty property (viewCharInput char (onChange >> updateNode)) ] 622 | IntProperty number onChange -> [ simpleInputProperty property (viewPositiveIntInput number (onChange >> updateNode)) ] 623 | ConnectingProperty currentSupplier onChange -> [ connectInputProperty property currentSupplier onChange Nothing ] 624 | 625 | ConnectingProperties countThem connectedProps onChange -> 626 | let 627 | count index prop = if countThem 628 | then { prop | name = String.fromInt (index + 1) ++ "." } 629 | else prop 630 | 631 | onChangePropertyAtIndex index newInput = case newInput of 632 | Just newInputId -> onChange (insertIntoArray index newInputId connectedProps) 633 | Nothing -> onChange (removeFromArray index connectedProps) 634 | 635 | onChangeStubProperty newInput = case newInput of 636 | Just newInputId -> onChange (Array.push newInputId connectedProps) 637 | Nothing -> onChange (removeFromArray ((Array.length connectedProps) - 1) connectedProps) 638 | 639 | viewRealProperty index supplier = 640 | let 641 | baseAttributes = (count index property) 642 | nodeExprString = (buildRegex nodes supplier) |> Result.map .expression |> Result.toMaybe 643 | 644 | in connectInputProperty baseAttributes (Just supplier) (onChangePropertyAtIndex index) nodeExprString 645 | 646 | realProperties = Array.toList <| Array.indexedMap viewRealProperty connectedProps 647 | 648 | propCount = Array.length connectedProps 649 | stubProperty = connectInputProperty (count propCount property) Nothing onChangeStubProperty Nothing 650 | 651 | in realProperties ++ [ stubProperty ] 652 | 653 | 654 | TitleProperty -> 655 | [ propertyHTML 656 | [ Mouse.onLeave (onLeave Nothing True) ] 657 | (div[][]) 658 | property.name property.description 659 | False 660 | (leftConnector False) 661 | (rightConnector property.connectOutput) 662 | ] 663 | 664 | in 665 | flattenList (List.map singleProperty props) 666 | 667 | 668 | onMouseWithStopPropagation eventName eventHandler = Mouse.onWithOptions 669 | eventName { preventDefault = False, stopPropagation = True } eventHandler 670 | 671 | stopMousePropagation eventName = 672 | onMouseWithStopPropagation eventName (always DoNothing) 673 | 674 | viewBoolInput : Bool -> Message -> Html Message 675 | viewBoolInput value onToggle = input 676 | [ type_ "checkbox" 677 | , checked value 678 | , onMouseWithStopPropagation "click" (always onToggle) 679 | , stopMousePropagation "mousedown" 680 | , stopMousePropagation "mouseup" 681 | ] 682 | [] 683 | 684 | 685 | viewCharsInput : String -> (String -> Message) -> Html Message 686 | viewCharsInput chars onChange = input 687 | [ type_ "text" 688 | , placeholder "chars" 689 | , value (insertWhitePlaceholder chars)-- FIXME this will reset the cursor position 690 | , onInput (removeWhitePlaceholder >> onChange) 691 | , class "chars input" 692 | , stopMousePropagation "mousedown" 693 | , stopMousePropagation "mouseup" 694 | ] 695 | [] 696 | 697 | viewCharInput : Char -> (Char -> Message) -> Html Message 698 | viewCharInput char onChange = input 699 | [ type_ "text" 700 | , placeholder "a" 701 | , value (String.fromChar char |> insertWhitePlaceholder) -- FIXME this will reset the cursor position 702 | 703 | -- Take the last char of the string 704 | , onInput (onChange << stringToChar char << removeWhitePlaceholder) 705 | 706 | , class "char input" 707 | , stopMousePropagation "mousedown" 708 | , stopMousePropagation "mouseup" 709 | ] 710 | [] 711 | 712 | 713 | viewPositiveIntInput : Int -> (Int -> Message) -> Html Message 714 | viewPositiveIntInput number onChange = input 715 | [ type_ "number" 716 | , value (String.fromInt number) 717 | , onInput (onChange << stringToInt number) 718 | , class "int input" 719 | , stopMousePropagation "mousedown" 720 | , stopMousePropagation "mouseup" 721 | , Html.Attributes.min "0" 722 | ] 723 | [] 724 | 725 | 726 | stringToInt fallback string = string 727 | |> String.toInt |> Maybe.withDefault fallback 728 | 729 | stringToChar fallback string = string 730 | |> String.right 1 |> String.uncons 731 | |> Maybe.map Tuple.first |> Maybe.withDefault fallback 732 | 733 | 734 | flattenList list = List.foldr (++) [] list 735 | 736 | removeFromList index list = 737 | List.take index list ++ List.drop (index + 1) list 738 | 739 | removeFromArray index = 740 | Array.toList >> removeFromList index >> Array.fromList 741 | 742 | insertIntoArray index element array = 743 | let -- TODO simplify! 744 | left = Array.slice 0 index array 745 | right = Array.slice index (Array.length array) array 746 | in Array.fromList ((Array.toList left) ++ [element] ++ (Array.toList right)) 747 | 748 | classes : String -> List (Bool, String) -> Attribute Message 749 | classes base elements = base ++ " " ++ 750 | (List.filterMap (\(condition, class) -> if condition then Just class else Nothing) elements |> String.join " ") 751 | |> class 752 | 753 | 754 | translateHTML = translate "px" 755 | translate unit position = style "transform" ("translate(" ++ (String.fromFloat position.x) ++ unit ++ "," ++ (String.fromFloat position.y) ++ unit ++ ")") 756 | 757 | magnifyAndOffsetHTML transformView = style "transform" (magnifyAndOffset "px" transformView) 758 | magnifyAndOffsetSVG transformView = Svg.Attributes.transform (magnifyAndOffset "" transformView) 759 | magnifyAndOffset unit transformView = 760 | let transform = viewTransform transformView 761 | in 762 | ( "translate(" ++ (String.fromFloat transform.translate.x) ++ unit ++ "," ++ (String.fromFloat transform.translate.y) ++ unit ++ ") " 763 | ++ "scale(" ++ (String.fromFloat transform.scale) ++ ")" 764 | ) 765 | 766 | --------------------------------------------------------------------------------