├── README.md ├── package.json ├── src ├── animPoly.js ├── index.html ├── index_release.html ├── main.js └── touchy.js └── tiramisu_ss.png /README.md: -------------------------------------------------------------------------------- 1 | # tiramisu 2 | A touch screen tree editor. The root is displayed at the bottom, its children are placed horizontally directly above it. 3 | 4 | ![Fibonacci example](/tiramisu_ss.png?raw=true) 5 | 6 | ## Demo 7 | * See it in action [on YouTube](https://youtu.be/Rr1tofDc_DQ) 8 | * Try it out at https://gashlin.net/tests/tiramisu/ 9 | 10 | ## Instructions 11 | * Tap a node to edit text 12 | * Drag a node up to add a parent 13 | * Drag a node left or right to add a sibling node 14 | * Drag a node down to delete a node 15 | * Long press to cut a node, it can be pasted from the edit screen 16 | * Pinch to zoom out 17 | 18 | ## Credits 19 | Inspired by a diagram on [a recipe by Michael Chu](http://www.cookingforengineers.com/recipe/60/The-Classic-Tiramisu-original-recipe) by way of Li Haoyi's article [What's Functional Programming All About?](http://www.lihaoyi.com/post/WhatsFunctionalProgrammingAllAbout.html). 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tiramisu", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "mkdir -p release && cat src/animPoly.js src/touchy.js src/main.js | babel --out-file release/combined.js --presets=env && cp -v src/index_release.html release/index.html" 6 | }, 7 | "files": [ 8 | "animPoly.js", 9 | "touchy.js", 10 | "main.js" 11 | ], 12 | "devDependencies": { 13 | "babel-cli": "^6.24.1", 14 | "babel-preset-env": "^1.5.2" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/animPoly.js: -------------------------------------------------------------------------------- 1 | // http://paulirish.com/2011/requestanimationframe-for-smart-animating/ 2 | // http://my.opera.com/emoller/blog/2011/12/20/requestanimationframe-for-smart-er-animating 3 | 4 | // requestAnimationFrame polyfill by Erik Möller 5 | // fixes from Paul Irish and Tino Zijdel 6 | (function() { 7 | var lastTime = 0; 8 | var vendors = ['ms', 'moz', 'webkit', 'o']; 9 | for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 10 | window.requestAnimationFrame = window[vendors[x]+'RequestAnimationFrame']; 11 | window.cancelAnimationFrame = 12 | window[vendors[x]+'CancelAnimationFrame'] || window[vendors[x]+'CancelRequestAnimationFrame']; 13 | } 14 | 15 | if (!window.requestAnimationFrame) 16 | window.requestAnimationFrame = function(callback, element) { 17 | var currTime = new Date().getTime(); 18 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 19 | var id = window.setTimeout(function() { callback(currTime + timeToCall); }, 20 | timeToCall); 21 | lastTime = currTime + timeToCall; 22 | return id; 23 | }; 24 | 25 | if (!window.cancelAnimationFrame) 26 | window.cancelAnimationFrame = function(id) { 27 | clearTimeout(id); 28 | }; 29 | }()); 30 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 48 | tiramisu tree editor 49 | 50 | 51 | 52 |
53 |
54 |
55 |
56 | 57 |
58 | 59 |
60 |
61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/index_release.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 48 | tiramisu tree editor 49 | 50 | 51 | 52 |
53 |
54 |
55 |
56 | 57 |
58 | 59 |
60 |
61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | 4 | //// graphics setup 5 | 6 | const dpr = window.devicePixelRatio ? window.devicePixelRatio : 1; 7 | const cnv = document.getElementById('cnv'); 8 | const ctx = cnv.getContext('2d'); 9 | 10 | let WIDE = window.innerWidth; 11 | let HIGH = window.innerHeight; 12 | 13 | let drawRequested = false; 14 | const draw = function(t) { 15 | drawRequested = false; 16 | 17 | ctx.fillStyle = 'white'; 18 | ctx.fillRect(0, 0, WIDE, HIGH); 19 | 20 | drawObjects(ctx); 21 | }; 22 | 23 | const requestDraw = function() { 24 | if (!drawRequested) { 25 | drawRequested = true; 26 | 27 | window.requestAnimationFrame(draw); 28 | } 29 | }; 30 | 31 | const resize = function() { 32 | WIDE = window.innerWidth; 33 | HIGH = window.innerHeight; 34 | 35 | cnv.width = WIDE * dpr; 36 | cnv.height = HIGH * dpr; 37 | 38 | cnv.style.width = `${WIDE}px`; 39 | cnv.style.height = `${HIGH}px`; 40 | 41 | ctx.setTransform(1, 0, 0, 1, 0, 0); 42 | ctx.scale(dpr, dpr); 43 | 44 | requestDraw(); 45 | }; 46 | 47 | window.addEventListener('resize', resize); 48 | window.addEventListener('focus', requestDraw); 49 | 50 | let CB_WIDE = 0; 51 | let CB_HIGH = 0; 52 | 53 | const cbCnv = document.getElementById('clipboard'); 54 | const cbCtx = cbCnv.getContext('2d'); 55 | 56 | const drawClipboard = function() { 57 | drawClipboardObjects(cbCtx); 58 | 59 | cbCtx.strokeStyle = 'black'; 60 | cbCtx.lineWidth = 3; 61 | cbCtx.strokeRect(1, 1, CB_WIDE - 2, CB_HIGH - 2); 62 | }; 63 | 64 | const resizeClipboard = function(wide, high) { 65 | CB_WIDE = wide; 66 | CB_HIGH = high; 67 | cbCnv.width = CB_WIDE * dpr; 68 | cbCnv.height = CB_HIGH * dpr; 69 | 70 | cbCnv.style.width = `${CB_WIDE}px`; 71 | cbCnv.style.height = `${CB_HIGH}px`; 72 | 73 | cbCtx.setTransform(1, 0, 0, 1, 0, 0); 74 | cbCtx.scale(dpr, dpr); 75 | }; 76 | 77 | //// mid-level touch handlers 78 | 79 | const dragDist = 10; 80 | let TOUCH_BEGAN = null; 81 | let TOUCH_NODE = null; 82 | let NEW_NODE = null; 83 | let EDITING_NODE = null; 84 | let DRAG_MODE = null; 85 | let DRAG_FEEL_X = 0; 86 | let DRAG_FEEL_Y = 0; 87 | let DRAG_LEFT_START_WIDTH = 0; 88 | let PINCH_BEGAN = null; 89 | let LONG_PRESS_TIMEOUT = null; 90 | 91 | const touchStart = function({x, y}){ 92 | x = (x - SCROLL.x) / ZOOM.z; 93 | y = (y - SCROLL.y) / ZOOM.z; 94 | 95 | TOUCH_BEGAN = {x, y}; 96 | TOUCH_NODE = nodeAt(0, 0, {x, y}, TREE); 97 | 98 | initDrag(); 99 | 100 | LONG_PRESS_TIMEOUT = window.setTimeout(longPress, 750); 101 | 102 | requestDraw(); 103 | }; 104 | 105 | const touchMove = function({x, y}){ 106 | if (!TOUCH_BEGAN) { 107 | return; 108 | } 109 | x = (x - SCROLL.x) / ZOOM.z; 110 | y = (y - SCROLL.y) / ZOOM.z; 111 | 112 | doDrag({x, y}); 113 | 114 | requestDraw(); 115 | }; 116 | 117 | const touchEnd = function({x, y}){ 118 | if (!TOUCH_BEGAN) { 119 | return; 120 | } 121 | x = (x - SCROLL.x) / ZOOM.z; 122 | y = (y - SCROLL.y) / ZOOM.z; 123 | 124 | if (DRAG_MODE) { 125 | doDrag({x, y}); 126 | dragDrop(); 127 | } else { 128 | // just a click 129 | doClick(TOUCH_BEGAN); 130 | } 131 | 132 | if (LONG_PRESS_TIMEOUT) { 133 | clearTimeout(LONG_PRESS_TIMEOUT); 134 | LONG_PRESS_TIMEOUT = null; 135 | } 136 | 137 | TOUCH_BEGAN = null; 138 | TOUCH_NODE = null; 139 | 140 | requestDraw(); 141 | }; 142 | 143 | const touchCancel = function() { 144 | if (!TOUCH_BEGAN) { 145 | return; 146 | } 147 | 148 | if (DRAG_MODE) { 149 | dragDrop() 150 | } 151 | 152 | TOUCH_BEGAN = null; 153 | TOUCH_NODE = null; 154 | 155 | if (LONG_PRESS_TIMEOUT) { 156 | clearTimeout(LONG_PRESS_TIMEOUT); 157 | LONG_PRESS_TIMEOUT = null; 158 | } 159 | 160 | requestDraw(); 161 | }; 162 | 163 | const pinchStart = function({x: x1, y: y1}, {x: x2, y: y2}) { 164 | PINCH_BEGAN = { p1: {x: x1, y: y1}, p2: {x: x2, y: y2} }; 165 | 166 | requestDraw(); 167 | }; 168 | 169 | const pinchMove = function({x: x1, y: y1}, {x: x2, y: y2}) { 170 | if (!PINCH_BEGAN) { 171 | return; 172 | } 173 | 174 | changeZoom({ 175 | ox1: PINCH_BEGAN.p1.x, oy1: PINCH_BEGAN.p1.y, 176 | ox2: PINCH_BEGAN.p2.x, oy2: PINCH_BEGAN.p2.y, 177 | nx1: x1, ny1: y1, 178 | nx2: x2, ny2: y2}); 179 | 180 | requestDraw(); 181 | }; 182 | 183 | const pinchEnd = function({x: x1, y: y1}, {x: x2, y: y2}) { 184 | if (!PINCH_BEGAN) { 185 | return; 186 | } 187 | 188 | pinchMove({x: x1, y: y1}, {x: x2, y: y2}); 189 | finishZoom(); 190 | 191 | PINCH_BEGAN = null; 192 | 193 | requestDraw(); 194 | }; 195 | 196 | //// register touch handlers 197 | GET_TOUCHY(cnv, { 198 | touchStart, 199 | touchMove, 200 | touchEnd, 201 | touchCancel, 202 | pinchStart, 203 | pinchMove, 204 | pinchEnd, 205 | }); 206 | 207 | window.addEventListener('wheel', function (e) { 208 | e.preventDefault(); 209 | 210 | const cx = e.pageX; 211 | const cy = e.pageY; 212 | let delta = -e.deltaY; 213 | 214 | if (e.deltaMode === 0x01) { 215 | delta *= 20; 216 | } 217 | if (e.deltaMode === 0x02) { 218 | delta *= 20 * 10; 219 | } 220 | 221 | changeZoomMouse({delta, cx, cy}); 222 | 223 | requestDraw(); 224 | }, {passive: false}); 225 | 226 | //// 227 | 228 | const initDrag = function() { 229 | DRAG_MODE = null; 230 | DRAG_FEEL_X = 0; 231 | DRAG_FEEL_Y = 0; 232 | }; 233 | 234 | const doDrag = function({x, y}) { 235 | const dx = x - TOUCH_BEGAN.x; 236 | const dy = y - TOUCH_BEGAN.y; 237 | DRAG_FEEL_X = dx; 238 | DRAG_FEEL_Y = dy; 239 | if (!DRAG_MODE) { 240 | 241 | if (LONG_PRESS_TIMEOUT) { 242 | clearTimeout(LONG_PRESS_TIMEOUT); 243 | LONG_PRESS_TIMEOUT = null; 244 | } 245 | // starting drag 246 | if (TOUCH_NODE) { 247 | // dragging a node 248 | if (dx > dragDist || dx < -dragDist) { 249 | if (dx > dragDist) { 250 | console.log('drag right'); 251 | DRAG_MODE = 'right'; 252 | } else { 253 | console.log('drag left'); 254 | DRAG_LEFT_START_WIDTH = TREE.width; 255 | DRAG_MODE = 'left'; 256 | } 257 | 258 | const p = TOUCH_NODE.handle ? null : findParent(TOUCH_NODE, TREE); 259 | if (p == null) { 260 | // root or handle can have no siblings 261 | DRAG_MODE = 'pan'; 262 | } else { 263 | NEW_NODE = {name: '', children: [], slidOver: 0}; 264 | if (dx > dragDist) { 265 | addSiblingBefore(p, TOUCH_NODE, NEW_NODE); 266 | } else { 267 | addSiblingAfter(p, TOUCH_NODE, NEW_NODE); 268 | } 269 | } 270 | } else if (dy > dragDist) { 271 | console.log('drag down'); 272 | DRAG_MODE = 'down'; 273 | 274 | if (TOUCH_NODE.handle) { 275 | // can't delete handles 276 | DRAG_MODE = 'pan'; 277 | } else if (!findParent(TOUCH_NODE, TREE)) { 278 | // don't want to make it easy to delete the whole tree 279 | DRAG_MODE = 'pan'; 280 | } else { 281 | TOUCH_NODE.slidOut = 0; 282 | TOUCH_NODE.slideUnder = true; 283 | } 284 | } else if (dy < -dragDist) { 285 | console.log('drag up'); 286 | DRAG_MODE = 'up'; 287 | 288 | const p = TOUCH_NODE.handle ? TOUCH_NODE.p : findParent(TOUCH_NODE, TREE); 289 | if (p == null) { 290 | // new root 291 | NEW_NODE = {name: '', children: [TREE], slidOut: 0}; 292 | TREE = NEW_NODE; 293 | } else if (TOUCH_NODE.handle) { 294 | NEW_NODE = {name: '', children: [], slidOut: 0}; 295 | p.children = [NEW_NODE]; 296 | } else { 297 | NEW_NODE = {name: '', children: [TOUCH_NODE], slidOut: 0}; 298 | replaceChild(p, TOUCH_NODE, NEW_NODE); 299 | } 300 | } 301 | } else { 302 | // dragging nothing, just pan 303 | DRAG_MODE = 'pan'; 304 | } 305 | } 306 | 307 | if (DRAG_MODE) { 308 | DRAG_FEEL_X = 0; 309 | DRAG_FEEL_Y = 0; 310 | } 311 | 312 | if (DRAG_MODE == 'left' || DRAG_MODE == 'right') { 313 | const slidOver = 314 | Math.max(0, Math.min(lineHeight, DRAG_MODE == 'right' ? dx : -dx)); 315 | NEW_NODE.slidOver = slidOver; 316 | 317 | measureTree(ctx, TREE); 318 | 319 | if (DRAG_MODE == 'left') { 320 | SCROLL.tx = DRAG_LEFT_START_WIDTH - TREE.width; 321 | } 322 | } else if (DRAG_MODE == 'down') { 323 | const slidOut = Math.max(0, Math.min(lineHeight, lineHeight - dy)); 324 | TOUCH_NODE.slidOut = slidOut; 325 | measureTree(ctx, TREE); 326 | } else if (DRAG_MODE == 'up') { 327 | const slidOut = Math.max(0, Math.min(lineHeight, -dy)); 328 | NEW_NODE.slidOut = slidOut; 329 | measureTree(ctx, TREE); 330 | } else if (DRAG_MODE == 'pan') { 331 | SCROLL.tx = dx * ZOOM.z; 332 | SCROLL.ty = dy * ZOOM.z; 333 | } 334 | }; 335 | 336 | const dragDrop = function() { 337 | if (!DRAG_MODE) { 338 | return; 339 | } 340 | 341 | if (DRAG_MODE == 'left' || DRAG_MODE == 'right') { 342 | if (NEW_NODE.slidOver < lineHeight) { 343 | const p = findParent(NEW_NODE, TREE); 344 | removeChild(p, NEW_NODE); 345 | } else { 346 | SCROLL.x += SCROLL.tx; 347 | 348 | showEditScreen(NEW_NODE); 349 | } 350 | SCROLL.tx = 0; 351 | 352 | NEW_NODE.slidOver = null; 353 | NEW_NODE = null; 354 | } else if (DRAG_MODE == 'down') { 355 | if (TOUCH_NODE.slidOut == 0) { 356 | const p = findParent(TOUCH_NODE, TREE); 357 | let lastChild = TOUCH_NODE; 358 | 359 | // copy my children as siblings 360 | for (let i = 0; i < TOUCH_NODE.children.length; ++i) { 361 | addSiblingAfter(p, lastChild, TOUCH_NODE.children[i]); 362 | lastChild = TOUCH_NODE.children[i]; 363 | } 364 | TOUCH_NODE.children = []; 365 | 366 | removeChild(p, TOUCH_NODE); 367 | } 368 | 369 | TOUCH_NODE.slidOut = null; 370 | TOUCH_NODE.slideUnder = false; 371 | } else if (DRAG_MODE == 'up') { 372 | if (NEW_NODE.slidOut < lineHeight) { 373 | // cancel new node 374 | if (NEW_NODE == TREE) { 375 | TREE = NEW_NODE.children[0]; 376 | } else if (TOUCH_NODE.handle) { 377 | TOUCH_NODE.p.children = []; 378 | } else { 379 | const p = findParent(NEW_NODE, TREE); 380 | replaceChild(p, NEW_NODE, TOUCH_NODE); 381 | } 382 | } else { 383 | showEditScreen(NEW_NODE); 384 | } 385 | 386 | NEW_NODE.slidOut = null; 387 | NEW_NODE = null; 388 | } else if (DRAG_MODE == 'pan') { 389 | SCROLL.x += SCROLL.tx; 390 | SCROLL.y += SCROLL.ty; 391 | SCROLL.tx = 0; 392 | SCROLL.ty = 0; 393 | } 394 | 395 | DRAG_MODE = null; 396 | DRAG_FEEL_X = 0; 397 | DRAG_FEEL_Y = 0; 398 | }; 399 | 400 | let CANCEL_PROMPT = null; 401 | const PROMPT = document.getElementById('prompt'); 402 | const PROMPT_FORM = document.getElementById('prompt-form'); 403 | const PROMPT_MSG = document.getElementById('prompt-msg'); 404 | const PROMPT_INPUT = document.getElementById('prompt-input'); 405 | 406 | const CLIPBOARD_MSG = document.getElementById('clipboard-msg'); 407 | 408 | const promptText = function(init, msg, cb, cbc) { 409 | if (typeof init !== 'string') { 410 | init = ''; 411 | } 412 | 413 | if (CANCEL_PROMPT) { 414 | CANCEL_PROMPT(); 415 | } 416 | 417 | PROMPT_MSG.textContent = msg; 418 | PROMPT.style.visibility = 'visible'; 419 | 420 | const submitHandler = function(e) { 421 | const value = PROMPT_INPUT.value; 422 | cancelPromptText(submitHandler); 423 | PROMPT_INPUT.blur(); 424 | e.preventDefault(); 425 | 426 | cb(value); 427 | }; 428 | 429 | PROMPT_FORM.addEventListener('submit', submitHandler); 430 | 431 | PROMPT_INPUT.value = init; 432 | PROMPT_INPUT.focus(); 433 | 434 | CANCEL_PROMPT = function () { 435 | cancelPromptText(submitHandler); 436 | if (cbc) { 437 | cbc(); 438 | } 439 | }; 440 | }; 441 | 442 | const cancelPromptText = function(submitHandler) { 443 | PROMPT_INPUT.blur(); 444 | PROMPT_INPUT.value = ''; 445 | PROMPT.style.visibility = 'hidden' 446 | PROMPT_FORM.removeEventListener('submit', submitHandler); 447 | CANCEL_PROMPT = null; 448 | }; 449 | 450 | const doClick = function({x, y}) { 451 | const node = nodeAt(0, 0, {x, y}, TREE); 452 | 453 | if (!node || node.handle) { 454 | return; 455 | } 456 | 457 | showEditScreen(node); 458 | }; 459 | 460 | const showEditScreen = function(node) { 461 | EDITING_NODE = node; 462 | 463 | drawClipboard(cbCtx); 464 | if (CLIPBOARD) { 465 | CLIPBOARD_MSG.textContent = 'Clipboard'; 466 | } else { 467 | CLIPBOARD_MSG.textContent = ''; 468 | } 469 | 470 | const takeClipboard = function(e) { 471 | CANCEL_PROMPT(); 472 | cbCnv.removeEventListener('click', takeClipboard); 473 | 474 | if (CLIPBOARD) { 475 | 476 | const newTree = copyTree(CLIPBOARD); 477 | 478 | const p = findParent(node, TREE); 479 | if (!p) { 480 | TREE = newTree; 481 | } else { 482 | replaceChild(p, node, newTree); 483 | } 484 | 485 | measureTree(ctx, TREE); 486 | } 487 | 488 | requestDraw(); 489 | }; 490 | 491 | cbCnv.addEventListener('click', takeClipboard); 492 | 493 | // display prompt 494 | promptText(node.name, 'Enter text', function(name) { 495 | node.name = name; 496 | node.textWidth = null; 497 | measureTree(ctx, TREE); 498 | cbCnv.removeEventListener('click', takeClipboard); 499 | 500 | EDITING_NODE = null; 501 | requestDraw(); 502 | }, function() { 503 | EDITING_NODE = null; 504 | }); 505 | }; 506 | 507 | const longPress = function() { 508 | const node = TOUCH_NODE; 509 | 510 | if (!node || node.handle) { 511 | return; 512 | } 513 | 514 | // long press: delete/cut 515 | const p = findParent(node, TREE); 516 | 517 | if (!p) { 518 | // don't cut root 519 | return; 520 | } 521 | 522 | removeChild(p, node); 523 | CLIPBOARD = node; 524 | 525 | TOUCH_NODE = null; 526 | TOUCH_BEGAN = null; 527 | 528 | requestDraw(); 529 | }; 530 | 531 | //// zooming 532 | const changeZoom = function({ox1, oy1, ox2, oy2, nx1, ny1, nx2, ny2}) { 533 | // "real" locations of the original zooming points 534 | const x1r = (ox1 - SCROLL.x) / ZOOM.z; 535 | const y1r = (oy1 - SCROLL.y) / ZOOM.z; 536 | const x2r = (ox2 - SCROLL.x) / ZOOM.z; 537 | const y2r = (oy2 - SCROLL.y) / ZOOM.z; 538 | const dxr = x1r - x2r; 539 | const dyr = y1r - y2r; 540 | 541 | // old distance 542 | const rd2 = dxr * dxr + dyr * dyr; 543 | // new distance 544 | const ndx = nx1 - nx2; 545 | const ndy = ny1 - ny2; 546 | const nd2 = ndx * ndx + ndy * ndy; 547 | // desired new zoom 548 | const z = Math.min(1, Math.sqrt(nd2 / rd2)); 549 | ZOOM.tz = z / ZOOM.z; 550 | 551 | // "real" location of original center 552 | const cxr = (x1r + x2r) / 2; 553 | const cyr = (y1r + y2r) / 2; 554 | // new center 555 | const ncx = (nx1 + nx2) / 2; 556 | const ncy = (ny1 + ny2) / 2; 557 | 558 | // desired new scroll 559 | const sx = ncx - cxr * z; 560 | const sy = ncy - cyr * z; 561 | SCROLL.tx = sx - SCROLL.x; 562 | SCROLL.ty = sy - SCROLL.y; 563 | }; 564 | 565 | const changeZoomMouse = function({delta, cx, cy}) { 566 | // desired new zoom 567 | const z = Math.min(1, ZOOM.z * Math.pow(2, delta / 100)); 568 | 569 | // "real" location of original center 570 | const cxr = (cx - SCROLL.x) / ZOOM.z; 571 | const cyr = (cy - SCROLL.y) / ZOOM.z; 572 | 573 | // desired new scroll 574 | const sx = cx - cxr * z; 575 | const sy = cy - cyr * z; 576 | SCROLL.x = sx; 577 | SCROLL.y = sy; 578 | ZOOM.z = z; 579 | }; 580 | 581 | const finishZoom = function() { 582 | SCROLL.x += SCROLL.tx; 583 | SCROLL.y += SCROLL.ty; 584 | SCROLL.tx = 0; 585 | SCROLL.ty = 0; 586 | ZOOM.z *= ZOOM.tz; 587 | ZOOM.tz = 1; 588 | }; 589 | 590 | //// tree manipulation 591 | const fontSize = 24; 592 | const lineHeight = fontSize * 2; 593 | 594 | /* 595 | const recursiveCall = 596 | {name: '+', 597 | children: [ 598 | {name: 'fib is the name of this thing', 599 | children: [ 600 | {name: '-', 601 | children: [ 602 | {name: 'n'}, 603 | {name: '1'}, 604 | ]}, 605 | {name: '-', 606 | children: [ 607 | {name: 'n'}, 608 | {name: '2'}, 609 | ]}, 610 | ]}, 611 | ] 612 | }; 613 | 614 | const defunFib = 615 | {name: 'defun', 616 | children: [ 617 | {name: 'fib'}, 618 | {name: '()', 619 | children: [ 620 | {name: 'x'}, 621 | ]}, 622 | {name: 'if', 623 | children: [ 624 | {name: '<', 625 | children: [ 626 | {name: 'n'}, 627 | {name: '2'}, 628 | ]}, 629 | {name: '1'}, 630 | recursiveCall, 631 | ]}, 632 | ] 633 | }; 634 | */ 635 | 636 | let CLIPBOARD = null; 637 | let TREE = {name: '', children: []}; 638 | let SCROLL = {x: 100.5, y: 100.5, tx: 0, ty: 0}; 639 | let ZOOM = {z: 1, tz: 1}; 640 | 641 | const measureTree = function(ctx, tree) { 642 | const measureTextWidth = function(text) { 643 | ctx.font = `${fontSize}px monospace`; 644 | return ctx.measureText(text).width; 645 | }; 646 | 647 | if (typeof tree.textWidth != 'number') { 648 | tree.textWidth = measureTextWidth(tree.name); 649 | } 650 | 651 | let nameWidth = Math.max(lineHeight, tree.textWidth + fontSize); 652 | if (typeof tree.slidOver == 'number') { 653 | nameWidth = Math.abs(tree.slidOver); 654 | } 655 | tree.childrenWidth = 0; 656 | if (tree.children) { 657 | tree.children.forEach(function(child) { 658 | measureTree(ctx, child); 659 | tree.childrenWidth += child.width; 660 | }); 661 | } 662 | 663 | if (nameWidth <= tree.childrenWidth) { 664 | tree.width = tree.childrenWidth; 665 | } else { 666 | tree.width = nameWidth; 667 | 668 | if (tree.children) { 669 | widenTree(tree); 670 | } 671 | } 672 | }; 673 | 674 | const widenTree = function(tree) { 675 | if (tree.childrenWidth < tree.width && tree.children) { 676 | let nonSlidingChildren = 0; 677 | 678 | tree.children.forEach(function(child) { 679 | if (typeof child.slidOver != 'number') { 680 | ++ nonSlidingChildren; 681 | } 682 | }); 683 | const adjust = (tree.width - tree.childrenWidth) / nonSlidingChildren; 684 | 685 | tree.children.forEach(function(child) { 686 | if (typeof child.slidOver != 'number') { 687 | child.width += adjust; 688 | widenTree(child); 689 | } 690 | }); 691 | tree.childrenWidth += adjust * nonSlidingChildren; 692 | } 693 | }; 694 | 695 | const drawTree = function(tree, x, y, idx, depth, layers, layerSolid, layerLines) { 696 | if (tree == TOUCH_NODE) { 697 | if (tree.slideUnder) { 698 | layerSolid = layers.bgSolid; 699 | layerLines = layers.bgLines; 700 | } else { 701 | x += DRAG_FEEL_X; 702 | y += DRAG_FEEL_Y; 703 | layerSolid = layers.fgSolid; 704 | layerLines = layers.fgLines; 705 | } 706 | } 707 | 708 | let height = lineHeight; 709 | if (typeof tree.slidOut == 'number') { 710 | height = tree.slidOut; 711 | } 712 | 713 | if (tree.children) { 714 | let childXOffset = 0; 715 | tree.children.forEach(function(child, childIdx) { 716 | drawTree(child, x + childXOffset, y - height, childIdx, depth + 1, 717 | layers, layerSolid, layerLines); 718 | childXOffset += child.width; 719 | }); 720 | } 721 | 722 | if (!tree.children || tree.children.length == 0) { 723 | // handle 724 | layerSolid.push( 725 | {op: 'strokeRect', strokeStyle: '#f0f0f0', lineWidth: 3, 726 | x: x + 1, 727 | y: y - height - lineHeight + 1, 728 | w: tree.width-2, 729 | h: lineHeight-2 730 | }); 731 | 732 | // highlight handle if it is being dragged 733 | if (TOUCH_NODE && TOUCH_NODE.handle && 734 | (TOUCH_NODE.p == tree || 735 | (TOUCH_NODE.p.children && TOUCH_NODE.p.children[0] == tree))) { 736 | layers.fgLines.push( 737 | {op: 'strokeRect', strokeStyle: 'black', 738 | lineWidth: 3, 739 | x: x, 740 | y: y - height - lineHeight, 741 | w: tree.width, 742 | h: lineHeight 743 | }); 744 | } 745 | } 746 | 747 | // main body of the node 748 | const boxFillStyle = depth % 2 == 0 ? '#f0f0f0' : '#e0e0e0'; 749 | layerSolid.push( 750 | {op: 'fillRect', fillStyle: boxFillStyle, 751 | x: x, 752 | y: y - height, 753 | w: tree.width, 754 | h: height 755 | }); 756 | 757 | // text label 758 | layerLines.push( 759 | {op: 'fillText', fillStyle: 'black', 760 | font: `${fontSize}px monospace`, 761 | textAlign: 'center', 762 | textBaseline: 'middle', 763 | msg: tree.name, 764 | cx: x + tree.width / 2, 765 | cy: y - height + lineHeight / 2 766 | }); 767 | 768 | // dividing line 769 | layerLines.push( 770 | {op: 'stroke', strokeStyle: 'black', 771 | lineWidth: 1, 772 | path: [[{x: x + tree.width, y: y - height}, 773 | {x: x + tree.width, y: y}]] 774 | }); 775 | 776 | // highlight node if it is being dragged 777 | // or if this is a new node and it is locked in (slid completely) 778 | if (tree == TOUCH_NODE || tree == EDITING_NODE || 779 | (tree == NEW_NODE && 780 | (tree.slidOver == lineHeight || tree.slidOut == lineHeight))) { 781 | layers.fgLines.push( 782 | {op: 'strokeRect', strokeStyle: 'black', 783 | lineWidth: 3, 784 | x: x, 785 | y: y - height, 786 | w: tree.width, 787 | h: height 788 | }); 789 | } 790 | }; 791 | 792 | const renderLayer = function(ctx, layer, sx, sy, z) { 793 | layer.forEach(function(cmd) { 794 | switch (cmd.op) { 795 | case 'fillText': 796 | ctx.save(); 797 | ctx.fillStyle = cmd.fillStyle; 798 | ctx.font = cmd.font; 799 | ctx.textAlign = cmd.textAlign; 800 | ctx.textBaseline = cmd.textBaseline; 801 | 802 | ctx.scale(z, z); 803 | ctx.translate(cmd.cx + sx / z, cmd.cy + sy / z); 804 | ctx.fillText(cmd.msg, 0, 0); 805 | ctx.restore(); 806 | break; 807 | case 'fillRect': 808 | ctx.fillStyle = cmd.fillStyle; 809 | ctx.fillRect(cmd.x * z + sx, cmd.y * z + sy, cmd.w * z, cmd.h * z); 810 | break; 811 | case 'strokeRect': 812 | ctx.strokeStyle = cmd.strokeStyle; 813 | ctx.lineWidth = cmd.lineWidth * z; 814 | ctx.strokeRect(cmd.x * z + sx, cmd.y * z + sy, cmd.w * z, cmd.h * z); 815 | break; 816 | case 'stroke': 817 | ctx.beginPath(); 818 | cmd.path.forEach(function(segment) { 819 | ctx.moveTo(segment[0].x * z + sx, segment[0].y * z + sy); 820 | for (let i = 1; i < segment.length; ++i) { 821 | ctx.lineTo(segment[i].x * z + sx, segment[i].y * z + sy); 822 | } 823 | }); 824 | ctx.strokeStyle = cmd.strokeStyle; 825 | ctx.lineWidth = cmd.lineWidth * z; 826 | ctx.stroke(); 827 | break; 828 | } 829 | }); 830 | }; 831 | 832 | const drawObjects = function(ctx) { 833 | measureTree(ctx, TREE); 834 | 835 | const layers = 836 | { 837 | bgSolid: [], 838 | bgLines: [], 839 | midSolid: [], 840 | midLines: [], 841 | fgSolid: [], 842 | fgLines: [], 843 | }; 844 | 845 | drawTree(TREE, 0, 0, 0, 0, layers, layers.midSolid, layers.midLines); 846 | 847 | const sx = SCROLL.x + SCROLL.tx; 848 | const sy = SCROLL.y + SCROLL.ty; 849 | const z = ZOOM.z * ZOOM.tz; 850 | 851 | renderLayer(ctx, layers.bgSolid, sx, sy, z); 852 | renderLayer(ctx, layers.bgLines, sx, sy, z); 853 | renderLayer(ctx, layers.midSolid, sx, sy, z); 854 | renderLayer(ctx, layers.midLines, sx, sy, z); 855 | renderLayer(ctx, layers.fgSolid, sx, sy, z); 856 | renderLayer(ctx, layers.fgLines, sx, sy, z); 857 | }; 858 | 859 | const drawClipboardObjects = function (ctx) { 860 | if (!CLIPBOARD) { 861 | return; 862 | } 863 | 864 | measureTree(cbCtx, CLIPBOARD); 865 | const depth = treeMaxDepth(CLIPBOARD, 1); 866 | const z = 0.5; 867 | 868 | const cw = Math.max(lineHeight, CLIPBOARD.width * z); 869 | const ch = Math.max(lineHeight, depth * lineHeight * z); 870 | resizeClipboard(cw, ch); 871 | 872 | cbCtx.fillStyle = 'white'; 873 | cbCtx.fillRect(0, 0, CB_WIDE, CB_HIGH); 874 | 875 | const sx = 0; 876 | const sy = CB_HIGH; 877 | 878 | const layers = { midSolid: [], midLines: [] }; 879 | 880 | drawTree(CLIPBOARD, 0, 0, 0, 0, layers, layers.midSolid, layers.midLines); 881 | 882 | renderLayer(ctx, layers.midSolid, sx, sy, z); 883 | renderLayer(ctx, layers.midLines, sx, sy, z); 884 | }; 885 | 886 | const nodeAt = function(treeX, treeY, {x, y}, tree) { 887 | if (x >= treeX && x < treeX + tree.width && 888 | y >= treeY - lineHeight && y < treeY) { 889 | return tree; 890 | } 891 | 892 | if (!tree.children || tree.children.length == 0) { 893 | // check for handle 894 | if (x >= treeX && x < treeX + tree.width && 895 | y >= treeY - lineHeight * 2 && y < treeY - lineHeight) { 896 | return {handle: true, p: tree}; 897 | } 898 | return null; 899 | } 900 | 901 | let childXOffset = 0; 902 | for (let i = 0; i < tree.children.length; ++i) { 903 | const child = tree.children[i]; 904 | const result = nodeAt(treeX + childXOffset, treeY - lineHeight, {x, y}, child); 905 | if (result) { 906 | return result; 907 | } 908 | childXOffset += child.width; 909 | } 910 | 911 | return null; 912 | }; 913 | 914 | const findParent = function(searchNode, tree) { 915 | if (!tree.children) { 916 | return null; 917 | } 918 | for (let i = 0; i < tree.children.length; ++i) { 919 | const child = tree.children[i]; 920 | if (child == searchNode) { 921 | return tree; 922 | } 923 | const result = findParent(searchNode, child); 924 | if (result) { 925 | return result; 926 | } 927 | } 928 | return null; 929 | }; 930 | 931 | const replaceChild = function(parentNode, oldNode, newNode) { 932 | if (!parentNode.children) { 933 | return; 934 | } 935 | 936 | for (let i = 0; i < parentNode.children.length; ++i) { 937 | const child = parentNode.children[i]; 938 | 939 | if (child == oldNode) { 940 | parentNode.children[i] = newNode; 941 | return; 942 | } 943 | } 944 | }; 945 | 946 | const removeChild = function(parentNode, node) { 947 | if (!parentNode.children) { 948 | return; 949 | } 950 | 951 | for (let i = 0; i < parentNode.children.length; ++i) { 952 | const child = parentNode.children[i]; 953 | 954 | if (child == node) { 955 | parentNode.children.splice(i, 1); 956 | return; 957 | } 958 | } 959 | }; 960 | 961 | const addSiblingBefore = function(parentNode, node, newNode) { 962 | if (!parentNode.children) { 963 | return; 964 | } 965 | 966 | for (let i = 0; i < parentNode.children.length; ++i) { 967 | const child = parentNode.children[i]; 968 | if (child == node) { 969 | parentNode.children.splice(i, 0, newNode); 970 | return; 971 | } 972 | } 973 | }; 974 | 975 | const addSiblingAfter = function(parentNode, node, newNode) { 976 | if (!parentNode.children) { 977 | return; 978 | } 979 | 980 | for (let i = 0; i < parentNode.children.length; ++i) { 981 | const child = parentNode.children[i]; 982 | if (child == node) { 983 | parentNode.children.splice(i + 1, 0, newNode); 984 | return; 985 | } 986 | } 987 | }; 988 | 989 | const copyTree = function(tree) { 990 | const newTree = {name: tree.name}; 991 | 992 | if (tree.children) { 993 | newTree.children = []; 994 | tree.children.forEach(function(child) { 995 | newTree.children.push(copyTree(child)); 996 | }); 997 | } 998 | 999 | // all metrics get computed elsewhere 1000 | return newTree; 1001 | }; 1002 | 1003 | const treeMaxDepth = function(tree, depth) { 1004 | let maxDepth = depth; 1005 | 1006 | if (tree.children && tree.children.length > 0) { 1007 | tree.children.forEach(function(child) { 1008 | const childDepth = treeMaxDepth(child, depth + 1); 1009 | maxDepth = Math.max(childDepth, maxDepth); 1010 | }); 1011 | } 1012 | return maxDepth 1013 | }; 1014 | 1015 | 1016 | //// kick off first draw 1017 | resize(); 1018 | resizeClipboard(CB_WIDE, CB_HIGH); 1019 | })(); 1020 | -------------------------------------------------------------------------------- /src/touchy.js: -------------------------------------------------------------------------------- 1 | /* exported GET_TOUCHY */ 2 | 3 | const GET_TOUCHY = function (elem, cb){ 4 | 'use strict'; 5 | 6 | let curTouches = []; 7 | let primaryIdx = -1; 8 | let pinchIdx1 = -1; 9 | let pinchIdx2 = -1; 10 | 11 | const startTouch = function (x, y, id, mouse) { 12 | const obj = { x, y }; 13 | if (mouse) { 14 | obj.mouse = true; 15 | } else { 16 | obj.touch = id; 17 | } 18 | 19 | curTouches.push(obj); 20 | if (primaryIdx !== -1) { 21 | cb.touchCancel(); 22 | } 23 | 24 | if (curTouches.length === 2 && (pinchIdx1 === -1 && pinchIdx2 === -1)) { 25 | pinchIdx1 = 0; 26 | pinchIdx2 = 1; 27 | primaryIdx = -1; 28 | cb.pinchStart(curTouches[pinchIdx1], curTouches[pinchIdx2]); 29 | } else { 30 | primaryIdx = curTouches.length - 1; 31 | cb.touchStart(obj); 32 | } 33 | } 34 | 35 | const updateTouch = function (idx, x, y) { 36 | const obj = curTouches[idx]; 37 | obj.x = x; 38 | obj.y = y; 39 | 40 | if (idx === primaryIdx) { 41 | cb.touchMove(obj); 42 | } 43 | }; 44 | 45 | const finishUpdateTouch = function () { 46 | if (curTouches.length === 2 && pinchIdx1 !== -1 && pinchIdx2 !== -1) { 47 | cb.pinchMove(curTouches[pinchIdx1], curTouches[pinchIdx2]); 48 | } 49 | }; 50 | 51 | const endTouch = function (idx, x, y, cancelled = false) { 52 | if (idx === primaryIdx) { 53 | if (cancelled) { 54 | cb.touchCancel(); 55 | } else { 56 | cb.touchEnd({x,y}); 57 | } 58 | primaryIdx = -1; 59 | } else if (primaryIdx !== -1 && primaryIdx > idx) { 60 | primaryIdx -= 1; 61 | } 62 | 63 | // slight inaccuracy here as the other pinch point may not have had a chance 64 | // to update yet this event 65 | if (idx === pinchIdx1) { 66 | if (pinchIdx2 !== -1) { 67 | cb.pinchEnd(curTouches[pinchIdx1], curTouches[pinchIdx2]); 68 | } 69 | pinchIdx1 = -1; 70 | } else if (pinchIdx1 !== -1 && pinchIdx1 > idx) { 71 | pinchIdx1 -= 1; 72 | } 73 | 74 | if (idx === pinchIdx2) { 75 | if (pinchIdx1 !== -1) { 76 | cb.pinchEnd(curTouches[pinchIdx1], curTouches[pinchIdx2]); 77 | } 78 | pinchIdx2 = -1; 79 | } else if (pinchIdx2 !== -1 && pinchIdx2 > idx) { 80 | pinchIdx2 -= 1; 81 | } 82 | 83 | curTouches.splice(idx, 1); 84 | } 85 | 86 | const touchIdx = function (id) { 87 | for (let i = 0; i < curTouches.length; i ++) { 88 | if (curTouches[i].touch === id) { 89 | return i; 90 | } 91 | } 92 | 93 | return -1; 94 | }; 95 | 96 | const mouseIdx = function () { 97 | for (let i = 0; i < curTouches.length; i ++) { 98 | if (curTouches[i].mouse) { 99 | return i; 100 | } 101 | } 102 | 103 | return -1; 104 | }; 105 | 106 | let sawPointerEvent = false; 107 | 108 | const handleTouchStart = function (e) { 109 | e.preventDefault(); 110 | e.stopPropagation(); 111 | 112 | for (let i = 0; i < e.changedTouches.length; i++) { 113 | const t = e.changedTouches[i]; 114 | startTouch(t.pageX, t.pageY, t.identifier); 115 | } 116 | }; 117 | 118 | const handlePointerDown = function (e) { 119 | e.preventDefault(); 120 | e.stopPropagation(); 121 | if (e.preventManipulation) { 122 | e.preventManipulation(); 123 | } 124 | 125 | if (!sawPointerEvent) { 126 | sawPointerEvent = true; 127 | curTouches.splice(0, curTouches.length); 128 | elem.removeEventListener('touchstart', handleTouchStart, {passive: false}); 129 | elem.removeEventListener('touchmove', handleTouchMove, {passive: false}); 130 | elem.removeEventListener('touchend', handleTouchEnd, {passive: false}); 131 | elem.removeEventListener('touchcancel', handleTouchCancel, {passive: false}); 132 | } 133 | 134 | startTouch(e.pageX, e.pageY, e.pointerId); 135 | }; 136 | 137 | const handleMouseDown = function (e) { 138 | if (e.button !== 0) { 139 | return; 140 | } 141 | 142 | e.preventDefault(); 143 | e.stopPropagation(); 144 | 145 | startTouch(e.pageX, e.pageY, null, true); 146 | }; 147 | 148 | const handleTouchMove = function (e) { 149 | e.preventDefault(); 150 | e.stopPropagation(); 151 | 152 | for (let i = 0; i < e.changedTouches.length; i++) { 153 | const t = e.changedTouches[i]; 154 | const idx = touchIdx(t.identifier); 155 | if (idx === -1) { 156 | continue; 157 | } 158 | 159 | updateTouch(idx, t.pageX, t.pageY); 160 | } 161 | 162 | finishUpdateTouch(); 163 | }; 164 | 165 | const handlePointerMove = function (e) { 166 | e.preventDefault(); 167 | e.stopPropagation(); 168 | if (e.preventManipulation) { 169 | e.preventManipulation(); 170 | } 171 | 172 | const idx = touchIdx(e.pointerId); 173 | if (idx !== -1) { 174 | updateTouch(idx, e.pageX, e.pageY); 175 | } 176 | 177 | finishUpdateTouch(); 178 | }; 179 | 180 | const handleMouseMove = function (e) { 181 | const idx = mouseIdx(); 182 | 183 | if (idx === -1) { 184 | return; 185 | } 186 | 187 | e.preventDefault(); 188 | e.stopPropagation(); 189 | 190 | updateTouch(idx, e.pageX, e.pageY); 191 | }; 192 | 193 | const handleTouchEnd = function (e) { 194 | e.preventDefault(); 195 | e.stopPropagation(); 196 | 197 | for (let i = 0; i < e.changedTouches.length; i++) { 198 | const t = e.changedTouches[i]; 199 | const idx = touchIdx(t.identifier); 200 | if (idx === -1) { 201 | continue; 202 | } 203 | 204 | endTouch(idx, t.pageX, t.pageY); 205 | } 206 | }; 207 | 208 | const handlePointerUp = function (e) { 209 | e.preventDefault(); 210 | e.stopPropagation(); 211 | if (e.preventManipulation) { 212 | e.preventManipulation(); 213 | } 214 | 215 | const idx = touchIdx(e.pointerId); 216 | if (idx !== -1) { 217 | endTouch(idx, e.pageX, e.pageY); 218 | } 219 | }; 220 | 221 | const handleMouseUp = function (e) { 222 | if (e.button !== 0) { 223 | return; 224 | } 225 | 226 | const idx = mouseIdx(); 227 | 228 | if (idx === -1) { 229 | return; 230 | } 231 | 232 | e.preventDefault(); 233 | e.stopPropagation(); 234 | 235 | endTouch(idx, e.pageX, e.pageY); 236 | }; 237 | 238 | const handleTouchCancel = function (e) { 239 | const touches = e.changedTouches; 240 | 241 | for (let i = 0; i < e.changedTouches.length; i++) { 242 | const t = e.changedTouches[i]; 243 | const idx = touchIdx(t.identifier); 244 | if (idx === -1) { 245 | continue; 246 | } 247 | 248 | endTouch(idx, t.pageX, t.pageY, true); 249 | } 250 | }; 251 | 252 | const handlePointerOut = function (e) { 253 | const idx = touchIdx(e.pointerId); 254 | 255 | if (idx !== -1) { 256 | endTouch(idx, e.pageX, e.pageY, true); 257 | } 258 | }; 259 | 260 | const handleMouseLeave = function (e) { 261 | const idx = mouseIdx(); 262 | 263 | if (idx === -1) { 264 | return; 265 | } 266 | 267 | e.stopPropagation(); 268 | 269 | endTouch(idx, e.pageX, e.pageY, true); 270 | }; 271 | 272 | elem.addEventListener('touchstart', handleTouchStart, {passive: false}); 273 | elem.addEventListener('touchmove', handleTouchMove, {passive: false}); 274 | elem.addEventListener('touchend', handleTouchEnd, {passive: false}); 275 | elem.addEventListener('touchcancel', handleTouchCancel, {passive: false}); 276 | 277 | elem.addEventListener('pointerdown', handlePointerDown, {passive: false}); 278 | elem.addEventListener('pointermove', handlePointerMove, {passive: false}); 279 | elem.addEventListener('pointerup', handlePointerUp, {passive: false}); 280 | elem.addEventListener('pointerout', handlePointerOut, {passive: false}); 281 | 282 | elem.addEventListener('mousedown', handleMouseDown, {passive: false}); 283 | elem.addEventListener('mousemove', handleMouseMove, {passive: false}); 284 | elem.addEventListener('mouseup', handleMouseUp, {passive: false}); 285 | elem.addEventListener('mouseleave', handleMouseLeave, {passive: false}); 286 | 287 | return curTouches; 288 | 289 | }; 290 | -------------------------------------------------------------------------------- /tiramisu_ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hcs64/tiramisu/96f1c82eef0a10b7b175553cf67bfd9fbaeae721/tiramisu_ss.png --------------------------------------------------------------------------------