├── README.md ├── LICENSE ├── ur.js ├── palette.js ├── action.js ├── edraw.html ├── image.js ├── circle.js ├── edraw.css ├── polygon.js ├── view.js ├── bez2.js ├── text.js ├── group.js ├── loadsave.js └── gui.js /README.md: -------------------------------------------------------------------------------- 1 | # edraw 2 | Simple vector drawing program 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Andrea Griffini 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 | -------------------------------------------------------------------------------- /ur.js: -------------------------------------------------------------------------------- 1 | function ur_begin(title) { 2 | if (++ur_level === 1) { 3 | redo_stack.splice(0, redo_stack.length); 4 | undo_stack.push([title]); 5 | } 6 | } 7 | 8 | function ur_end() { 9 | --ur_level; 10 | } 11 | 12 | function ur_add(step) { 13 | undo_stack[undo_stack.length-1].push(step); 14 | step.redo(); 15 | invalidate(); 16 | } 17 | 18 | function ur_set(obj, index, value) { 19 | let ov = obj[index]; 20 | ur_add({undo:()=>{ obj[index] = ov; }, 21 | redo:()=>{ obj[index] = value; }}); 22 | } 23 | 24 | function ur_vset(sgf, value) { 25 | let ov = sgf(); 26 | ur_add({undo:()=>{ sgf(ov); }, 27 | redo:()=>{ sgf(value); }}); 28 | } 29 | 30 | function undo() { 31 | if (undo_stack.length) { 32 | let op = undo_stack.pop(); 33 | redo_stack.push(op); 34 | for (let i=op.length-1; i>0; i--) { 35 | op[i].undo(); 36 | } 37 | invalidate(); 38 | } 39 | } 40 | 41 | function redo() { 42 | if (redo_stack.length) { 43 | let op = redo_stack.pop(); 44 | undo_stack.push(op); 45 | for (let i=1; i{ drawingDialog(false); }); 2 | addButton("{cloud_upload} Save", ()=>{ drawingDialog(true); }); 3 | 4 | addSpace(); 5 | 6 | addButton("{undo} Undo", ()=>{ editor=undefined; undo(); }); 7 | addButton("{redo} Redo", ()=>{ editor=undefined; redo(); }); 8 | addSpace(); 9 | addButton("Add Circle", draw_circle); 10 | addButton("Add Curve", draw_curve); 11 | addButton("Add Polygon", draw_polygon); 12 | addButton("Add Text", draw_text); 13 | 14 | addSpace(); 15 | 16 | addButton("{arrow_upward} Up", ()=> editor && updown_entity(editor.e, 1)); 17 | addButton("{arrow_downward} Down", () => editor && updown_entity(editor.e, -1)); 18 | addButton("{vertical_align_top} Top", () => editor && updown_entity(editor.e, Infinity)); 19 | addButton("{vertical_align_bottom} Bottom", () => editor && updown_entity(editor.e, -Infinity)); 20 | 21 | addSpace(); 22 | 23 | addButton("{delete} Delete", ()=>{ editor && delete_entity(editor.e) }); 24 | addButton("{add_circle} Clone", ()=>{ editor && clone_entity(editor.e) }); 25 | 26 | addSpace(); 27 | 28 | let palette = document.createElement("div"); 29 | palette.className = "palette"; 30 | 31 | btnbar.appendChild(palette); 32 | for (let i=0; i<8; i++) { 33 | let colbtn = palette.appendChild(document.createElement("div")), 34 | col = "rgb(" + ((i>>2)&1)*255 + "," + ((i>>1)&1)*255 + "," + (i&1)*255 + ")"; 35 | colbtn.style.backgroundColor = col; 36 | colbtn.className = "colbtn"; 37 | colbtn.value = ""; 38 | colbtn.onmousedown = (event)=>{ 39 | event.preventDefault(); 40 | event.stopPropagation(); 41 | if (editor) { 42 | ur_begin("Color change"); 43 | editor.e.setStyle(event.shiftKey ? "stroke" : "fill", col); 44 | ur_end(); 45 | } 46 | }; 47 | if (i % 2 == 1) { 48 | palette.appendChild(document.createElement("div")); 49 | } 50 | } 51 | 52 | addSpace(); 53 | -------------------------------------------------------------------------------- /action.js: -------------------------------------------------------------------------------- 1 | 2 | function draw_polygon() { 3 | editor = { 4 | text: "Add Polygon: [left] on start and drag", 5 | draw(ctx) { 6 | }, 7 | hit(x, y, b) { 8 | let p = rmap(x, y); 9 | let c = new Polygon([p, p, p, p], {fill:"#008"}); 10 | ur_begin("Polygon draw"); 11 | ur_add({undo(){ entities.pop(); }, redo(){ entities.push(c); }}); 12 | ur_end(); 13 | editor = new PolygonEditor(entities[entities.length-1]); 14 | repaint(); 15 | let first = true; 16 | track((x, y) => { 17 | let p = rmap(x, y); 18 | if (!first) undo(); 19 | ur_begin("Polygon draw"); 20 | ur_set(c.pts, 2, p); 21 | ur_set(c.pts, 1, {x:p.x, y:c.pts[0].y}); 22 | ur_set(c.pts, 3, {x:c.pts[0].x, y:p.y}); 23 | ur_end(); 24 | first = false; 25 | }); 26 | return true; 27 | } 28 | }; 29 | repaint(); 30 | } 31 | 32 | function updown_entity(e, delta) { 33 | let i = entities.indexOf(e); 34 | if (i >= 0) { 35 | let oe = entities, ne = oe.slice(0, i).concat(oe.slice(i+1)), 36 | j = Math.max(0, Math.min(ne.length, i+delta)); 37 | if (j !== i) { 38 | ne.splice(j, 0, e); 39 | ur_begin("Change entity depth"); 40 | ur_add({undo: ()=>{ entities=oe; }, redo: ()=>{ entities=ne; }}); 41 | ur_end(); 42 | } 43 | } 44 | } 45 | 46 | function delete_entity(e) { 47 | let i = entities.indexOf(e); 48 | if (i >= 0) { 49 | let ne = entities.slice(0, i).concat(entities.slice(i+1)), oe = entities; 50 | ur_begin("Entity delete"); 51 | ur_add({undo:()=>{ entities=oe; }, redo:()=>{ entities=ne; }}); 52 | ur_end(); 53 | editor = undefined; 54 | } 55 | } 56 | 57 | function clone_entity(e) { 58 | let x = e.clone(); 59 | ur_begin("Duplicate object"); 60 | x.transform(p => ({x:p.x+16/sf, y:p.y+16/sf})); 61 | let ne = entities.concat([x]), oe = entities; 62 | ur_add({undo:()=>{ entities=oe; }, redo:()=>{ entities=ne; }}); 63 | ur_end(); 64 | editor = x.editor(); 65 | } 66 | -------------------------------------------------------------------------------- /edraw.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /image.js: -------------------------------------------------------------------------------- 1 | class Image { 2 | constructor(p0, p1, image, style) { 3 | this.p0 = p0; 4 | this.p1 = p1; 5 | this.image = image; 6 | } 7 | editor() { 8 | return new ImageEditor(this); 9 | } 10 | clone() { 11 | return new Image(this.p0, this.p1, this.image); 12 | } 13 | setStyle(f, val) { 14 | } 15 | draw(ctx) { 16 | drawImage(ctx, this.p0, this.p1, this.image); 17 | } 18 | hp() { 19 | let ox = -(this.p1.y - this.p0.y), oy = this.p1.x - this.p0.x, 20 | L = (ox**2 + oy**2)**0.5, 21 | nx = -ox/L, ny = -oy/L, 22 | r = dist(this.p0, this.p1) * this.image.height / this.image.width; 23 | return {x:this.p0.x+nx*r, y:this.p0.y+ny*r}; 24 | } 25 | hit(x, y, b) { 26 | let p = rmap(x, y); 27 | let hp = this.hp(), dx = hp.x-this.p0.x, dy = hp.y-this.p0.y; 28 | if (inside(p, [this.p0, this.p1, {x:this.p1.x+dx, y:this.p1.y+dy}, {x:this.p0.x+dx, y:this.p0.y+dy}])) { 29 | if (b === 0) { 30 | drag(this); 31 | } else if (b === 2) { 32 | editPopup(this, x, y); 33 | } 34 | return true; 35 | } 36 | } 37 | bbox() { 38 | let hp = this.hp(), dx = hp.x-this.p0.x, dy = hp.y-this.p0.y; 39 | let c = {x0:Math.min(this.p0.x, this.p0.x+dx, this.p1.x, this.p1.x+dx), 40 | y0:Math.min(this.p0.y, this.p0.y+dy, this.p1.y, this.p1.y+dy), 41 | x1:Math.max(this.p0.x, this.p0.x+dx, this.p1.x, this.p1.x+dx), 42 | y1:Math.max(this.p0.y, this.p0.y+dy, this.p1.y, this.p1.y+dy)}; 43 | return c; 44 | } 45 | transform(m) { 46 | let mp0 = m(this.p0), mp1 = m(this.p1); 47 | ur_set(this, "p0", mp0); 48 | ur_set(this, "p1", mp1); 49 | } 50 | }; 51 | 52 | class ImageEditor { 53 | constructor(e) { 54 | this.e = e; 55 | this.text = "Move image base point or change angle or size"; 56 | } 57 | draw(ctx) { 58 | let hp0 = this.e.hp(), 59 | dx = hp0.x - this.e.p0.x, dy = hp0.y - this.e.p0.y, 60 | hp1 = {x:this.e.p1.x+dx, y:this.e.p1.y+dy}; 61 | drawLine(ctx, this.e.p0, this.e.p1); 62 | drawLine(ctx, this.e.p1, hp1); 63 | drawLine(ctx, hp1, hp0); 64 | drawLine(ctx, hp0, this.e.p0); 65 | dot(ctx, this.e.p0); 66 | dot(ctx, this.e.p1); 67 | } 68 | hit(x, y, b) { 69 | let p = rmap(x, y); 70 | if (dist(p, this.e.p0) < 8/sf || dist(p, this.e.p1) < 8/sf) { 71 | let first = true, 72 | w = dist(p, this.e.p0) < dist(p, this.e.p1) ? "p0" : "p1"; 73 | track((x, y, button, phase, mods) => { 74 | let p = rmap(x, y); 75 | if (!first) undo(); 76 | ur_begin("Image point drag"); 77 | if (w === "p0") { 78 | let dx = this.e.p1.x - this.e.p0.x, 79 | dy = this.e.p1.y - this.e.p0.y; 80 | ur_set(this.e, "p0", p); 81 | ur_set(this.e, "p1", {x:p.x + dx, y:p.y + dy}); 82 | } else { 83 | if (mods & track.SHIFT) { 84 | let a = Math.atan2(p.y - this.e.p0.y, p.x - this.e.p0.x), 85 | d = ((p.x - this.e.p0.x)**2 + (p.y - this.e.p0.y)**2) ** 0.5; 86 | a = Math.floor(a / (Math.PI/8) + 0.5) * (Math.PI/8); 87 | p = {x: this.e.p0.x + d*Math.cos(a), 88 | y: this.e.p0.y + d*Math.sin(a)}; 89 | } 90 | ur_set(this.e, "p1", p); 91 | } 92 | ur_end(); 93 | first = false; 94 | }); 95 | return true; 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /circle.js: -------------------------------------------------------------------------------- 1 | class Circle { 2 | constructor(center, radius, style) { 3 | this.center = center; 4 | this.radius = radius; 5 | this.style = style; 6 | } 7 | editor() { 8 | return new CircleEditor(this); 9 | } 10 | clone() { 11 | return new Circle(this.center, this.radius, clone(this.style)); 12 | } 13 | setStyle(f, val) { 14 | ur_set(this.style, f, val); 15 | } 16 | draw(ctx) { 17 | if (this.style.fill) { 18 | fillCircle(ctx, this.center, this.radius, this.style.fill); 19 | } 20 | if (this.style.stroke) { 21 | drawCircle(ctx, this.center, this.radius, this.style.stroke, this.style.width); 22 | } 23 | } 24 | hit(x, y, b) { 25 | let p = rmap(x, y); 26 | if (dist(p, this.center) < Math.max(this.radius, 8/sf)) { 27 | if (b === 0) { 28 | drag(this); 29 | } else if (b === 2) { 30 | editPopup(this, x, y); 31 | } 32 | return true; 33 | } 34 | } 35 | bbox() { 36 | return {x0:this.center.x - this.radius, 37 | y0:this.center.y - this.radius, 38 | x1:this.center.x + this.radius, 39 | y1:this.center.y + this.radius}; 40 | } 41 | transform(m) { 42 | ur_set(this, "center", m(this.center)); 43 | } 44 | }; 45 | 46 | class CircleEditor { 47 | constructor(e) { 48 | this.e = e; 49 | this.text = "Drag circle center or change circle radius"; 50 | } 51 | draw(ctx) { 52 | let p = {x: this.e.center.x + this.e.radius, y:this.e.center.y}; 53 | drawLine(ctx, this.e.center, p); 54 | dot(ctx, this.e.center); 55 | dot(ctx, p); 56 | } 57 | hit(x, y, b) { 58 | let p = rmap(x, y); 59 | if (dist(p, {x:this.e.center.x+this.e.radius, y:this.e.center.y}) < 8/sf) { 60 | let first = true; 61 | track((x, y) => { 62 | if (!first) undo(); 63 | ur_begin("Circle radius drag"); 64 | ur_set(this.e, "radius", dist(rmap(x, y), this.e.center)); 65 | ur_end(); 66 | first = false; 67 | }); 68 | return true; 69 | } 70 | if (dist(p, this.e.center) < 8/sf) { 71 | let first = true; 72 | track((x, y) => { 73 | let q = rmap(x, y); 74 | let np = {x: this.e.center.x + q.x - p.x, 75 | y: this.e.center.y + q.y - p.y}; 76 | p = q; 77 | if (!first) undo(); 78 | ur_begin("Circle center drag"); 79 | ur_set(this.e, "center", np); 80 | ur_end(); 81 | first = false; 82 | }); 83 | return true; 84 | } 85 | } 86 | } 87 | 88 | function draw_circle() { 89 | editor = { 90 | text: "Draw circle: [left] on center and drag for radius", 91 | draw(ctx) { 92 | }, 93 | hit(x, y, b) { 94 | let c = new Circle(rmap(x, y), 0, {fill:"#ABC"}); 95 | ur_begin("Circle draw"); 96 | ur_add({undo(){ entities.pop(); }, redo(){ entities.push(c); }}); 97 | ur_end(); 98 | editor = new CircleEditor(entities[entities.length-1]); 99 | repaint(); 100 | let first = true; 101 | track((x, y) => { 102 | if (!first) undo(); 103 | ur_begin("Circle radius drag"); 104 | ur_set(c, "radius", dist(rmap(x, y), c.center)); 105 | ur_end(); 106 | first = false; 107 | }); 108 | return true; 109 | } 110 | }; 111 | repaint(); 112 | } 113 | -------------------------------------------------------------------------------- /edraw.css: -------------------------------------------------------------------------------- 1 | html { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | body { 6 | padding: 0px; 7 | margin: 0px; 8 | overflow: hidden; 9 | background: #444; 10 | width: 100%; 11 | height: 100%; 12 | user-select: none; 13 | -moz-user-select: none; 14 | } 15 | #canvas { 16 | position: absolute; 17 | left: 0px; 18 | top: 0px; 19 | } 20 | #status { 21 | position: absolute; 22 | left: 0px; 23 | right: 0px; 24 | bottom: 8px; 25 | color: #CCC; 26 | font-size: 24px; 27 | font-family: sans-serif; 28 | text-align: center; 29 | line-height: 2em; 30 | } 31 | #status > .key { 32 | border-radius: 6px; 33 | background: #888; 34 | color: #222; 35 | box-shadow: inset 1px 1px #FFF, 2px 2px 2px rgba(0,0,0,0.5); 36 | padding: 2px; 37 | padding-left: 8px; 38 | padding-right: 8px; 39 | font-weight: bold; 40 | margin: 2px; 41 | } 42 | #btnbar { 43 | position: absolute; 44 | right: 0px; 45 | top: 0px; 46 | background: rgba(0,0,0,0.25); 47 | } 48 | #btnbar > button { 49 | padding: 3px; 50 | font-size: 16px; 51 | font-weight: bold; 52 | border-radius: 4px; 53 | background: #CCC; 54 | width: 120px; 55 | display: block; 56 | margin: 4px; 57 | } 58 | .palette { 59 | text-align: center; 60 | } 61 | #btnbar > .space { 62 | height: 8px; 63 | } 64 | .colbtn { 65 | width: 40px; 66 | height: 40px; 67 | margin: 4px; 68 | border-radius: 4px; 69 | display: inline-block; 70 | box-shadow: inset 2px 2px 3px rgba(255, 255, 255, 0.5), inset -2px -2px 3px rgba(0, 0, 0, 0.5) 71 | } 72 | .menu { 73 | position: absolute; 74 | padding: 8px; 75 | padding-top: 0px; 76 | background: #888; 77 | box-shadow: inset 1px 1px 1px #FFF, inset -1px -1px 1px #000, 4px 4px 4px rgba(0,0,0, 0.25); 78 | border-radius: 4px; 79 | color: #000; 80 | font-size: 18px; 81 | font-weight: bold; 82 | font-family: sans-serif; 83 | } 84 | .menu > .title { 85 | font-size: 20; 86 | margin-top: 8px; 87 | color: #FFF; 88 | } 89 | .menu > .option { 90 | border-radius: 2px; 91 | padding: 2px; 92 | padding-bottom: 0px; 93 | padding-left: 8px; 94 | padding-right: 8px; 95 | } 96 | .menu > .option:hover { 97 | background: #FFF; 98 | } 99 | .glass { 100 | position: fixed; 101 | left: 0px; 102 | top: 0px; 103 | right: 0px; 104 | bottom: 0px; 105 | } 106 | 107 | .dialog { 108 | position: fixed; 109 | left: 10%; 110 | right: 10%; 111 | top: 10%; 112 | bottom: 10%; 113 | background: #CCC; 114 | border-radius: 8px; 115 | box-shadow: 4px 4px 4px rgba(0, 0, 0, 0.25); 116 | padding: 2px; 117 | } 118 | 119 | .dialog > .title { 120 | position: absolute; 121 | left: 2px; 122 | right: 2px; 123 | top: 2px; 124 | background: linear-gradient(90deg, #008, #00F, #008); 125 | color: #FFF; 126 | font-weight: bold; 127 | font-size: 22px; 128 | font-family: sans-serif; 129 | padding: 4px; 130 | border-top-left-radius: 8px; 131 | border-top-right-radius: 8px; 132 | text-align: center; 133 | height: 28px; 134 | } 135 | 136 | .dialog > .client { 137 | position: absolute; 138 | left: 2px; 139 | top: 40px; 140 | bottom: 2px; 141 | right: 2px; 142 | padding: 16px; 143 | background: #EEE; 144 | box-shadow: inset 2 2 2px #000; 145 | border-bottom-left-radius: 8px; 146 | border-bottom-right-radius: 8px; 147 | overflow: auto; 148 | } 149 | 150 | .dialog > .client > .entry { 151 | font-size: 16px; 152 | font-family: sans-serif; 153 | display: inline-block; 154 | padding: 4px; 155 | margin: 4px; 156 | color: #000; 157 | background: #FFF; 158 | padding: 8px; 159 | box-shadow: 2px 2px 2px rgba(0,0,0, 0.25); 160 | border: solid 2px rgba(0,0,0,0); 161 | width: 64px; 162 | height: 64px; 163 | } 164 | 165 | .dialog > .client > .entry:hover { 166 | border: solid 2px #F00; 167 | transform: scale(1.25, 1.25); 168 | } 169 | 170 | .dialog > .closebox { 171 | position: absolute; 172 | top: 7px; 173 | right: 8px; 174 | font-size: 18px; 175 | font-weight: bold; 176 | color: #FFF; 177 | background-color: #F00; 178 | border-radius: 4px; 179 | padding: 3px; 180 | padding-left: 8px; 181 | padding-right: 8px; 182 | } 183 | -------------------------------------------------------------------------------- /polygon.js: -------------------------------------------------------------------------------- 1 | class Polygon { 2 | constructor(pts, style) { 3 | this.pts = pts; 4 | this.style = style; 5 | this.smooth = false; 6 | } 7 | editor() { 8 | return new PolygonEditor(this); 9 | } 10 | clone() { 11 | return new Polygon(this.pts.slice(), clone(this.style)); 12 | } 13 | setStyle(f, val) { 14 | ur_set(this.style, f, val); 15 | } 16 | draw(ctx) { 17 | if (this.style.stroke) drawPolygon(ctx, this.pts, this.style.stroke, this.style.width); 18 | if (this.style.fill) fillPolygon(ctx, this.pts, this.style.fill); 19 | } 20 | hit(x, y, b) { 21 | let p = rmap(x, y) 22 | if (inside(p, this.pts)) { 23 | if (b === 0) { 24 | drag(this); 25 | } else { 26 | editPopup(this, x, y); 27 | } 28 | return true; 29 | } 30 | } 31 | bbox() { 32 | let X = this.pts.map(p=>p.x), Y = this.pts.map(p=>p.y); 33 | return {x0: Math.min(...X), 34 | y0: Math.min(...Y), 35 | x1: Math.max(...X), 36 | y1: Math.max(...Y)}; 37 | } 38 | transform(m) { 39 | for (let i=0; i { 64 | if (!first) undo(); 65 | ur_begin("Polygon point drag"); 66 | ur_set(this.e.pts, i, rmap(x, y)); 67 | ur_end(); 68 | first = false; 69 | }); 70 | return true; 71 | } else { 72 | ur_begin("Polygon point delete"); 73 | ur_set(this.e, "pts", this.e.pts.slice(0, i).concat(this.e.pts.slice(i+1))); 74 | ur_end(); 75 | return true; 76 | } 77 | } 78 | } 79 | for (let i=0,n=this.e.pts.length,j=n-1; i { 87 | if (!first) undo(); 88 | ur_begin("Polygon point drag"); 89 | ur_set(this.e.pts, i, rmap(x, y)); 90 | ur_end(); 91 | first = false; 92 | }); 93 | return true; 94 | } 95 | } 96 | } 97 | } 98 | 99 | function draw_polygon() { 100 | editor = { 101 | text: "Add Polygon: [left] on start and drag", 102 | draw(ctx) { 103 | }, 104 | hit(x, y, b) { 105 | let p = rmap(x, y); 106 | let c = new Polygon([p, p, p, p], {fill:"#008"}); 107 | ur_begin("Polygon draw"); 108 | ur_add({undo(){ entities.pop(); }, redo(){ entities.push(c); }}); 109 | ur_end(); 110 | editor = new PolygonEditor(entities[entities.length-1]); 111 | repaint(); 112 | let first = true; 113 | track((x, y) => { 114 | let p = rmap(x, y); 115 | if (!first) undo(); 116 | ur_begin("Polygon draw"); 117 | ur_set(c.pts, 2, p); 118 | ur_set(c.pts, 1, {x:p.x, y:c.pts[0].y}); 119 | ur_set(c.pts, 3, {x:c.pts[0].x, y:p.y}); 120 | ur_end(); 121 | first = false; 122 | }); 123 | return true; 124 | } 125 | }; 126 | repaint(); 127 | } 128 | -------------------------------------------------------------------------------- /view.js: -------------------------------------------------------------------------------- 1 | function map({x, y}) { 2 | return {x: x*sf+zx, y: y*sf+zy}; 3 | } 4 | 5 | function rmap(x, y) { 6 | return {x: (x-zx)/sf, y: (y-zy)/sf}; 7 | } 8 | 9 | function dist(a, b) { 10 | return ((a.x - b.x)**2 + (a.y - b.y)**2) ** 0.5; 11 | } 12 | 13 | function lerp(a, b, t) { 14 | return a*(1-t) + b*t; 15 | } 16 | 17 | function lerp2(a, b, c, t) { 18 | return (a*(1-t)+b*t)*(1-t) + (b*(1-t)+c*t)*t; 19 | } 20 | 21 | function avg(a, b){ 22 | return {x:(a.x+b.x)/2, y:(a.y+b.y)/2}; 23 | } 24 | 25 | function inside(p, pts) { 26 | let r = false; 27 | for (let i=0,n=pts.length,j=n-1; i= b.y && p.y < a.y) || (p.y >= a.y && p.y < b.y)) { 30 | if (a.x + (p.y - a.y)*(b.x - a.x)/(b.y - a.y) < p.x) r = !r; 31 | } 32 | } 33 | return r; 34 | } 35 | 36 | function dot(ctx, p, color="#F00", r=-4){ 37 | ctx.beginPath(); 38 | ctx.arc(p.x*sf+zx, p.y*sf+zy, r<0 ? -r : r*sf, 0, 2*Math.PI, true); 39 | ctx.fillStyle = color; 40 | ctx.fill(); 41 | } 42 | 43 | function drawLine(ctx, a, b, color="#F00", width=-1) { 44 | ctx.beginPath(); 45 | ctx.moveTo(a.x*sf+zx, a.y*sf+zy); 46 | ctx.lineTo(b.x*sf+zx, b.y*sf+zy); 47 | ctx.strokeStyle = color; 48 | ctx.lineWidth = width<0 ? -width : width*sf; 49 | ctx.stroke(); 50 | } 51 | 52 | function bez2Interp(a, b, c) { 53 | let dx0 = b.x - a.x, 54 | dy0 = b.y - a.y, 55 | dx1 = c.x - b.x, 56 | dy1 = c.y - b.y, 57 | L0 = (dx0*dx0 + dy0*dy0)**0.5, 58 | L1 = (dx1*dx1 + dy1*dy1)**0.5, 59 | m = (L0 + L1) == 0 ? 0.5 : L0 / (L0 + L1); 60 | // at2 + bt + c 61 | // c = P[0] 62 | // a + b = P[2] - P[0] 63 | // am² + bm = P[1] - P[0] 64 | // a + b/m = (P[1] - P[0])/m² 65 | // b = ((P[1] - P[0])/m² - (P[2] - P[0]))/(1/m - 1) 66 | return {x: a.x + 0.5 * ((b.x-a.x)/(m*m) - (c.x - a.x)) / (1/m - 1), 67 | y: a.y + 0.5 * ((b.y-a.y)/(m*m) - (c.y - a.y)) / (1/m - 1)}; 68 | } 69 | 70 | function drawBez2(ctx, a, b, c, color="#F00", width=-1) { 71 | ctx.beginPath(); 72 | ctx.moveTo(a.x*sf+zx, a.y*sf+zy); 73 | ctx.quadraticCurveTo(b.x*sf+zx, b.y*sf+zy, c.x*sf+zx, c.y*sf+zy); 74 | ctx.strokeStyle = color; 75 | ctx.lineWidth = width<0 ? -width : width*sf; 76 | ctx.stroke(); 77 | } 78 | 79 | function drawCircle(ctx, p, r, color="#F00", width=-1) { 80 | ctx.beginPath(); 81 | ctx.arc(p.x*sf+zx, p.y*sf+zy, r*sf, 0, 2*Math.PI, true); 82 | ctx.strokeStyle = color; 83 | ctx.lineWidth = width<0 ? -width : width*sf; 84 | ctx.stroke(); 85 | } 86 | 87 | function fillCircle(ctx, p, r, color) { 88 | ctx.beginPath(); 89 | ctx.arc(p.x*sf+zx, p.y*sf+zy, r*sf, 0, 2*Math.PI, true); 90 | ctx.fillStyle = color; 91 | ctx.fill(); 92 | } 93 | 94 | function drawPolygon(ctx, pts, color="#F00", width=-1) { 95 | ctx.beginPath(); 96 | pts.forEach((p, i) => { 97 | if (i) { 98 | ctx.lineTo(p.x*sf+zx, p.y*sf+zy); 99 | } else { 100 | ctx.moveTo(p.x*sf+zx, p.y*sf+zy); 101 | } 102 | }); 103 | ctx.closePath(); 104 | ctx.strokeStyle = color; 105 | ctx.lineWidth = width<0 ? -width : width*sf; 106 | ctx.stroke(); 107 | } 108 | 109 | function fillPolygon(ctx, pts, color="#F00") { 110 | ctx.beginPath(); 111 | pts.forEach((p, i) => { 112 | if (i) { 113 | ctx.lineTo(p.x*sf+zx, p.y*sf+zy); 114 | } else { 115 | ctx.moveTo(p.x*sf+zx, p.y*sf+zy); 116 | } 117 | }); 118 | ctx.closePath(); 119 | ctx.fillStyle = color; 120 | ctx.fill(); 121 | } 122 | 123 | function drawText(ctx, p0, p1, text, color="#F00") { 124 | ctx.save(); 125 | ctx.beginPath(); 126 | ctx.textAlign = "left"; 127 | ctx.textBaseline = "alphabetic"; 128 | ctx.font = dist(p0, p1)*sf + "px sans-serif"; 129 | ctx.fillStyle = color; 130 | ctx.translate(p0.x*sf+zx, p0.y*sf+zy); 131 | ctx.rotate(Math.atan2(p1.y-p0.y, p1.x-p0.x)+Math.PI/2); 132 | ctx.fillText(text, 0, 0); 133 | ctx.restore(); 134 | } 135 | 136 | function textWidth(p0, p1, text) { 137 | ctx.font = dist(p0, p1) + "px sans-serif"; 138 | return ctx.measureText(text).width; 139 | } 140 | 141 | function drawImage(ctx, p0, p1, img) { 142 | ctx.save(); 143 | ctx.beginPath(); 144 | ctx.translate(p0.x*sf+zx, p0.y*sf+zy); 145 | ctx.rotate(Math.atan2(p1.y - p0.y, p1.x - p0.x)); 146 | let w = sf * dist(p0, p1), 147 | h = w * img.height / img.width; 148 | ctx.drawImage(img, 0, -h, w, h); 149 | ctx.restore(); 150 | } 151 | -------------------------------------------------------------------------------- /bez2.js: -------------------------------------------------------------------------------- 1 | class Bez2 { 2 | constructor(a, b, c, flags, style) { 3 | this.a = a; 4 | this.b = b; 5 | this.c = c; 6 | this.flags = flags; 7 | this.style = style; 8 | } 9 | editor() { 10 | return new Bez2Editor(this); 11 | } 12 | clone() { 13 | return new Bez2(this.a, this.b, this.c, this.flags, clone(this.style)); 14 | } 15 | setStyle(f, val) { 16 | ur_set(this.style, f, val); 17 | } 18 | draw(ctx) { 19 | let b = bez2Interp(this.a, this.b, this.c); 20 | drawBez2(ctx, this.a, b, this.c, 21 | this.style.stroke, this.style.width); 22 | let arrow = (a, b)=>{ 23 | let dx = b.x - a.x, dy = b.y - a.y, 24 | L = (dx*dx + dy*dy)**0.5, 25 | wx = dx/L*50, wy = dy/L*50; 26 | fillPolygon(ctx, [{x:b.x+wx/4, y:b.y+wy/4}, 27 | {x:b.x-wx+wy/3, y:b.y-wy-wx/3}, 28 | {x:b.x-wx-wy/3, y:b.y-wy+wx/3}], 29 | this.style.stroke); 30 | }; 31 | if (this.flags & 1) arrow(b, this.a); 32 | if (this.flags & 2) arrow(b, this.c); 33 | } 34 | hit(x, y, b) { 35 | let p = rmap(x, y) 36 | for (let i=0; i<=100; i++) { 37 | let bp = bez2Interp(this.a, this.b, this.c); 38 | let xx = lerp2(this.a.x, bp.x, this.c.x, i/100), 39 | yy = lerp2(this.a.y, bp.y, this.c.y, i/100); 40 | if (dist({x:xx, y:yy}, p) < Math.max(this.style.width/2, 8/sf)) { 41 | if (b === 0) { 42 | drag(this); 43 | } else { 44 | editPopup(this, x, y, 45 | "Curve", 46 | {text:"{arrow_forward} Toggle end arrow", action:()=>{this.editor().arrow(1)}}, 47 | {text:"{arrow_back} Toggle start arrow", action:()=>{this.editor().arrow(-1)}}, 48 | ); 49 | 50 | } 51 | return true; 52 | } 53 | } 54 | } 55 | bbox() { 56 | let b = bez2Interp(this.a, this.b, this.c); 57 | let x0=this.a.x, y0=this.a.y, x1=x0, y1=y0; 58 | for (let i=1; i<=10; i++) { 59 | let xx = lerp2(this.a.x, b.x, this.c.x, i/10), 60 | yy = lerp2(this.a.y, b.y, this.c.y, i/10); 61 | x0 = Math.min(xx, x0); x1 = Math.max(xx, x1); 62 | y0 = Math.min(yy, y0); y1 = Math.max(yy, y1); 63 | } 64 | return {x0, y0, x1, y1}; 65 | } 66 | transform(m) { 67 | ur_set(this, "a", m(this.a)); 68 | ur_set(this, "b", m(this.b)); 69 | ur_set(this, "c", m(this.c)); 70 | } 71 | } 72 | 73 | class Bez2Editor { 74 | constructor(e) { 75 | this.e = e; 76 | this.text = "Drag control points"; 77 | } 78 | draw(ctx) { 79 | dot(ctx, this.e.a); dot(ctx, this.e.b); dot(ctx, this.e.c); 80 | } 81 | arrow(d) { 82 | if (d === -1) { 83 | ur_begin("Bez2 curve start arrow toggle"); 84 | ur_set(this.e, "flags", this.e.flags ^ 1); 85 | ur_end(); 86 | } else if (d === 1) { 87 | ur_begin("Bez2 curve end arrow toggle"); 88 | ur_set(this.e, "flags", this.e.flags ^ 2); 89 | ur_end(); 90 | } 91 | invalidate(); 92 | } 93 | hit(x, y, b) { 94 | let p = rmap(x, y); 95 | if (b === 0) { 96 | for (let dname of "abc") { 97 | if (dist(p, this.e[dname]) < 8/sf) { 98 | let first = true; 99 | track((x, y) => { 100 | if (!first) undo(); 101 | ur_begin("Bez2 point drag"); 102 | ur_set(this.e, dname, rmap(x, y)); 103 | ur_end(); 104 | first = false; 105 | }); 106 | return true; 107 | } 108 | } 109 | } 110 | } 111 | } 112 | 113 | function draw_curve() { 114 | editor = { 115 | text: "Draw Curve: [left] on start and drag", 116 | draw(ctx) { 117 | }, 118 | hit(x, y, b) { 119 | let p = rmap(x, y); 120 | let c = new Bez2(p, p, p, 0, {stroke:"#000", width:8}); 121 | ur_begin("Curve draw"); 122 | ur_add({undo(){ entities.pop(); }, redo(){ entities.push(c); }}); 123 | ur_end(); 124 | editor = new Bez2Editor(entities[entities.length-1]); 125 | repaint(); 126 | let first = true; 127 | track((x, y) => { 128 | let p = rmap(x, y); 129 | if (!first) undo(); 130 | ur_begin("Curve draw"); 131 | ur_set(c, "c", p); 132 | ur_set(c, "b", {x:(c.a.x+c.c.x)/2, y:(c.a.y+c.c.y)/2}); 133 | ur_end(); 134 | first = false; 135 | }); 136 | return true; 137 | } 138 | }; 139 | repaint(); 140 | } 141 | -------------------------------------------------------------------------------- /text.js: -------------------------------------------------------------------------------- 1 | class Text { 2 | constructor(p0, p1, text, style) { 3 | this.p0 = p0; 4 | this.p1 = p1; 5 | this.text = text; 6 | this.style = style; 7 | } 8 | editor() { 9 | return new TextEditor(this); 10 | } 11 | clone() { 12 | return new Text(this.p0, this.p1, this.text, clone(this.style)); 13 | } 14 | setStyle(f, val) { 15 | ur_set(this.style, f, val); 16 | } 17 | draw(ctx) { 18 | drawText(ctx, this.p0, this.p1, this.text||"", this.style.fill); 19 | } 20 | hp() { 21 | let w = textWidth(this.p0, this.p1, this.text||""); 22 | let ox = -(this.p1.y - this.p0.y), oy = this.p1.x - this.p0.x, 23 | L = (ox**2 + oy**2)**0.5, 24 | nx = ox/L, ny = oy/L; 25 | return {x:this.p0.x+nx*w, y:this.p0.y+ny*w}; 26 | } 27 | hit(x, y, b) { 28 | let p = rmap(x, y); 29 | let hp = this.hp(), dx = hp.x-this.p0.x, dy = hp.y-this.p0.y; 30 | if (inside(p, [this.p0, this.p1, {x:this.p1.x+dx, y:this.p1.y+dy}, {x:this.p0.x+dx, y:this.p0.y+dy}])) { 31 | if (b === 0) { 32 | drag(this); 33 | } else if (b === 2) { 34 | editPopup(this, x, y); 35 | } 36 | return true; 37 | } 38 | } 39 | bbox() { 40 | let hp = this.hp(), dx = hp.x-this.p0.x, dy = hp.y-this.p0.y; 41 | let c = {x0:Math.min(this.p0.x, this.p0.x+dx, this.p1.x, this.p1.x+dx), 42 | y0:Math.min(this.p0.y, this.p0.y+dy, this.p1.y, this.p1.y+dy), 43 | x1:Math.max(this.p0.x, this.p0.x+dx, this.p1.x, this.p1.x+dx), 44 | y1:Math.max(this.p0.y, this.p0.y+dy, this.p1.y, this.p1.y+dy)}; 45 | return c; 46 | } 47 | transform(m) { 48 | let mp0 = m(this.p0), mp1 = m(this.p1); 49 | ur_set(this, "p0", mp0); 50 | ur_set(this, "p1", mp1); 51 | } 52 | }; 53 | 54 | class TextEditor { 55 | constructor(e) { 56 | this.e = e; 57 | this.text = "Move text base point or change text angle or size"; 58 | } 59 | draw(ctx) { 60 | let hp = this.e.hp(); 61 | drawLine(ctx, this.e.p0, this.e.p1); 62 | drawLine(ctx, this.e.p0, hp); 63 | dot(ctx, this.e.p0); 64 | dot(ctx, this.e.p1); 65 | } 66 | hit(x, y, b) { 67 | let p = rmap(x, y), hp = this.e.hp(); 68 | if (dist(p, this.e.p0) < 8/sf || dist(p, this.e.p1) < 8/sf) { 69 | let first = true, 70 | w = dist(p, this.e.p0) < dist(p, this.e.p1) ? "p0" : "p1"; 71 | track((x, y, b, phase, mods) => { 72 | let p = rmap(x, y); 73 | if (!first) undo(); 74 | ur_begin("Text point drag"); 75 | if (w === "p0") { 76 | let dx = this.e.p1.x - this.e.p0.x, 77 | dy = this.e.p1.y - this.e.p0.y; 78 | ur_set(this.e, "p0", p); 79 | ur_set(this.e, "p1", {x: p.x + dx, y: p.y + dy}); 80 | } else { 81 | if (mods & track.SHIFT) { 82 | let a = Math.atan2(p.y - this.e.p0.y, p.x - this.e.p0.x), 83 | d = ((p.x - this.e.p0.x) ** 2 + (p.y - this.e.p0.y) ** 2) ** 0.5; 84 | a = Math.floor(a / (Math.PI / 8) + 0.5) * (Math.PI / 8); 85 | p = { 86 | x: this.e.p0.x + d * Math.cos(a), 87 | y: this.e.p0.y + d * Math.sin(a) 88 | }; 89 | } 90 | ur_set(this.e, "p1", p); 91 | } 92 | ur_end(); 93 | first = false; 94 | }); 95 | return true; 96 | } 97 | } 98 | key(k) { 99 | if (k === "Backspace") { 100 | if (this.e.text.length) { 101 | ur_begin("Text edit"); 102 | ur_set(this.e, "text", this.e.text.slice(0, -1)); 103 | ur_end(); 104 | } 105 | } else if (k.length === 1) { 106 | ur_begin("Text edit"); 107 | ur_set(this.e, "text", this.e.text + k); 108 | ur_end(); 109 | } 110 | } 111 | } 112 | 113 | function draw_text() { 114 | editor = { 115 | text: "Draw text: [left] on base point and drag for angle", 116 | draw(ctx) { 117 | }, 118 | hit(x, y, b) { 119 | let p = rmap(x, y), 120 | c = new Text(p, {x:p.x, y:p.y-50}, "", {fill:"#000"}); 121 | ur_begin("Text draw"); 122 | ur_add({undo(){ entities.pop(); }, redo(){ entities.push(c); }}); 123 | ur_end(); 124 | editor = new TextEditor(entities[entities.length-1]); 125 | repaint(); 126 | let first = true; 127 | track((x, y) => { 128 | let p = rmap(x, y); 129 | if (!first) undo(); 130 | ur_begin("Text line drag"); 131 | ur_set(c, "p0", p); 132 | ur_set(c, "p1", {x:p.x, y:p.y-50}); 133 | ur_end(); 134 | first = false; 135 | }); 136 | return true; 137 | } 138 | }; 139 | repaint(); 140 | } 141 | -------------------------------------------------------------------------------- /group.js: -------------------------------------------------------------------------------- 1 | class Group { 2 | constructor(r, entities) { 3 | this.r = r; 4 | this.entities = entities; 5 | this.crot = {x:(r.x0+r.x1)/2, y:(r.y0+r.y1)/2}; 6 | } 7 | editor() { 8 | return new GroupEditor(this); 9 | } 10 | clone() { 11 | let g = new Group(clone(this.r), this.entities.map(e=>e.clone())); 12 | g.crot = this.crot; 13 | return g; 14 | } 15 | setStyle(f, val) { 16 | this.entities.forEach(e => e.setStyle(f, val)); 17 | } 18 | draw(ctx) { 19 | this.entities.forEach(e => e.draw(ctx)); 20 | } 21 | hit(x, y, b) { 22 | let pt = rmap(x, y); 23 | if (pt.x >= this.r.x0 && pt.y >= this.r.y0 && pt.x <= this.r.x1 && pt.y <= this.r.y1) { 24 | if (b === 0) { 25 | drag(this); 26 | } else { 27 | editPopup(this, x, y, 28 | "Group", 29 | {text:"{center_focus_weak} ungroup", action:()=>{this.editor().explode()}}); 30 | } 31 | return true; 32 | } 33 | } 34 | bbox() { 35 | return this.r; 36 | } 37 | transform(m) { 38 | let bb = undefined; 39 | this.entities.forEach(e => { 40 | e.transform(m); 41 | let b = e.bbox(); 42 | if (bb === undefined) { 43 | bb = b; 44 | } else { 45 | bb.x0 = Math.min(bb.x0, b.x0); 46 | bb.y0 = Math.min(bb.y0, b.y0); 47 | bb.x1 = Math.max(bb.x1, b.x1); 48 | bb.y1 = Math.max(bb.y1, b.y1); 49 | } 50 | }); 51 | ur_set(this, "crot", m(this.crot)); 52 | ur_set(this, "r", bb); 53 | } 54 | } 55 | 56 | class GroupEditor { 57 | constructor(e) { 58 | this.e = e; 59 | } 60 | draw(ctx) { 61 | let a = {x:this.e.r.x0, y:this.e.r.y0}, 62 | b = {x:this.e.r.x1, y:this.e.r.y0}, 63 | c = {x:this.e.r.x1, y:this.e.r.y1}, 64 | d = {x:this.e.r.x0, y:this.e.r.y1}; 65 | dot(ctx, a); dot(ctx, b); dot(ctx, c); dot(ctx, d); 66 | dot(ctx, this.e.crot, "#0F0"); 67 | drawLine(ctx, a, b); 68 | drawLine(ctx, b, c); 69 | drawLine(ctx, c, d); 70 | drawLine(ctx, d, a); 71 | } 72 | hit(x, y, b) { 73 | let p = rmap(x, y); 74 | if (b === 0) { 75 | if (dist(p, this.e.crot) < 8/sf) { 76 | let first = true; 77 | track((x, y, b) => { 78 | if (!first) undo(); 79 | ur_begin("Rotation center drag"); 80 | ur_set(this.e, "crot", rmap(x, y)); 81 | ur_end(); 82 | first = false; 83 | }); 84 | return true; 85 | } else { 86 | let a = {x:this.e.r.x0, y:this.e.r.y0}, 87 | b = {x:this.e.r.x1, y:this.e.r.y0}, 88 | c = {x:this.e.r.x1, y:this.e.r.y1}, 89 | d = {x:this.e.r.x0, y:this.e.r.y1}; 90 | if (Math.min(dist(a, p), dist(b, p), dist(c, p), dist(d, p)) < 8/sf) { 91 | let first = true, a0 = Math.atan2(p.y-this.e.crot.y, p.x-this.e.crot.x), 92 | cx = this.e.crot.x, cy =this.e.crot.y; 93 | track((x, y, b, phase, mods) => { 94 | let p = rmap(x, y); 95 | if (!first) undo(); 96 | ur_begin("Group rotate"); 97 | let aa = Math.atan2(p.y-this.e.crot.y, p.x-this.e.crot.x) - a0; 98 | if (mods & track.SHIFT) { 99 | aa = Math.floor(aa / (Math.PI/8) + 0.5) * (Math.PI/8); 100 | } 101 | let cs = Math.cos(aa), sn = Math.sin(aa); 102 | function r(p) { 103 | let dx = p.x - cx, dy = p.y - cy; 104 | return {x: cx + dx*cs - dy*sn, 105 | y: cy + dy*cs + dx*sn}; 106 | } 107 | let bb = undefined; 108 | this.e.entities.forEach(e => { 109 | e.transform(r); 110 | let b = e.bbox(); 111 | if (bb === undefined) { 112 | bb = b; 113 | } else { 114 | bb.x0 = Math.min(bb.x0, b.x0); 115 | bb.y0 = Math.min(bb.y0, b.y0); 116 | bb.x1 = Math.max(bb.x1, b.x1); 117 | bb.y1 = Math.max(bb.y1, b.y1); 118 | } 119 | }); 120 | ur_set(this.e, "r", bb); 121 | ur_end(); 122 | first = false; 123 | }); 124 | return true; 125 | } else if (p.x >= this.e.r.x0 && p.y >= this.e.r.y0 && p.x <= this.e.r.x1 && p.y <= this.e.r.y1) { 126 | drag(this.e); 127 | return true; 128 | } 129 | } 130 | } 131 | } 132 | explode() { 133 | let i = entities.indexOf(this.e); 134 | if (i >= 0) { 135 | let ne = entities.slice(0, i).concat(this.e.entities).concat(entities.slice(i+1)), oe = entities; 136 | ur_begin("Group explode"); 137 | ur_add({undo: ()=>{ entities=oe; }, redo: ()=>{ entities=ne; }}); 138 | ur_end(); 139 | editor = undefined; 140 | invalidate(); 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /loadsave.js: -------------------------------------------------------------------------------- 1 | let EntityTypes = [Circle, Bez2, Polygon, Group, Text, Image], 2 | EntityTypeNames = EntityTypes.map(t => t.name); 3 | 4 | function save(entities) { 5 | let data = JSON.stringify(entities, 6 | (key, e) => { 7 | if (e && EntityTypes.indexOf(e.constructor) >= 0) { 8 | let t = {type: e.constructor.name}; 9 | for (let k of Object.keys(e)) { 10 | t[k] = k === "image" ? e[k].src : e[k]; 11 | } 12 | return t; 13 | } else { 14 | return e; 15 | } 16 | }); 17 | return data; 18 | } 19 | 20 | function load(s, f) { 21 | let c = 0, 22 | doc = JSON.parse(s, 23 | (key, e) => { 24 | let i = e && e.type ? EntityTypeNames.indexOf(e.type) : -1; 25 | if (i >= 0) { 26 | let ne = Object.create(EntityTypes[i].prototype); 27 | for (let k of Object.keys(e)) { 28 | if (k !== 'type') { 29 | if (k === "image") { 30 | let img = ne[k] = document.createElement("img"); 31 | c++; 32 | img.onload = ()=>{ 33 | if (--c === 0) f(doc); 34 | }; 35 | img.src = e[k]; 36 | } else { 37 | ne[k] = e[k]; 38 | } 39 | } 40 | } 41 | return ne; 42 | } 43 | return e; 44 | }); 45 | if (c === 0) f(doc); 46 | } 47 | 48 | function icon(entities) { 49 | let bb = undefined; 50 | entities.forEach(e => { 51 | let b = e.bbox(); 52 | if (bb === undefined) { 53 | bb = b; 54 | } else { 55 | bb.x0 = Math.min(bb.x0, b.x0); 56 | bb.y0 = Math.min(bb.y0, b.y0); 57 | bb.x1 = Math.max(bb.x1, b.x1); 58 | bb.y1 = Math.max(bb.y1, b.y1); 59 | } 60 | }); 61 | let canvas = document.createElement("canvas"), ctx = canvas.getContext("2d"); 62 | canvas.width = canvas.height = 64; 63 | if (bb !== undefined) { 64 | canvas.width = canvas.height = 64; 65 | let osf = sf, ozx = zx; ozy = zy; 66 | sf = Math.min(64/(bb.x1 - bb.x0), 64/(bb.y1 - bb.y0)); 67 | zx = 32 - sf*(bb.x0 + bb.x1)/2; 68 | zy = 32 - sf*(bb.y0 + bb.y1)/2; 69 | entities.forEach(e => e.draw(ctx)); 70 | sf = osf; zx = ozx; zy = ozy; 71 | } 72 | return canvas; 73 | } 74 | 75 | function drawingDialog(saving) { 76 | let g = document.createElement("div"), 77 | d = g.appendChild(document.createElement("div")), 78 | t = d.appendChild(document.createElement("div")), 79 | c = d.appendChild(document.createElement("div")), 80 | x = d.appendChild(document.createElement("div")); 81 | g.className = "glass"; 82 | d.className = "dialog"; 83 | x.className = "closebox"; 84 | x.textContent = "×"; 85 | x.onmousedown = (event) => { 86 | event.preventDefault(); 87 | event.stopPropagation(); 88 | document.body.removeChild(g); 89 | }; 90 | t.className = "title"; 91 | t.textContent = "Drawing archive"; 92 | c.className = "client"; 93 | let drawings = (localStorage.getItem("edraw_archive") || "").split("\n"); 94 | if (drawings[drawings.length-1] === "") drawings.pop(); 95 | for (let i=0; i { 113 | event.preventDefault(); 114 | event.stopPropagation(); 115 | drawings[i] = save(entities); 116 | localStorage.setItem("edraw_archive", drawings.join("\n")); 117 | document.body.removeChild(g); 118 | }; 119 | e.appendChild(ic); 120 | } else { 121 | load(drawings[i], (ee)=>{ 122 | ic = icon(ee); 123 | if (saving) { 124 | ic.onmousedown = (event) => { 125 | event.preventDefault(); 126 | event.stopPropagation(); 127 | drawings[i] = save(entities); 128 | localStorage.setItem("edraw_archive", drawings.join("\n")); 129 | document.body.removeChild(g); 130 | }; 131 | } else { 132 | ic.onmousedown = (event) => { 133 | event.preventDefault(); 134 | event.stopPropagation(); 135 | entities = ee; 136 | undo_stack = []; 137 | redo_stack = []; 138 | ur_level = 0; 139 | editor = undefined; 140 | sf = 1; zx = 0; zy = 0; 141 | invalidate(); 142 | document.body.removeChild(g); 143 | }; 144 | } 145 | e.appendChild(ic); 146 | }); 147 | } 148 | } 149 | document.body.appendChild(g); 150 | } 151 | -------------------------------------------------------------------------------- /gui.js: -------------------------------------------------------------------------------- 1 | function popup(x, y, ...options) { 2 | show_popup(x, y, undefined, options); 3 | } 4 | 5 | function show_popup(x, y, parent, options) { 6 | let glass = document.createElement("div"), 7 | menu = glass.appendChild(document.createElement("div")), 8 | enter = false; 9 | glass.className = "glass"; 10 | menu.className = "menu"; 11 | menu.style.left = x + "px"; 12 | menu.style.top = y + "px"; 13 | options.forEach(o => { 14 | if (typeof o === "string") { 15 | let i = menu.appendChild(document.createElement("div")); 16 | i.className = "title"; 17 | i.innerHTML = rtext(o); 18 | } else { 19 | let i = menu.appendChild(document.createElement("div")); 20 | i.className = "option"; 21 | i.innerHTML = rtext(o.text); 22 | i.onmousedown = i.onmouseup = (event) => { 23 | event.preventDefault(); 24 | event.stopPropagation(); 25 | document.body.removeChild(glass); 26 | o.action(); 27 | }; 28 | i.onmouseenter = ()=>{ enter = true; }; 29 | } 30 | }); 31 | glass.onmouseup = (event) => { 32 | event.preventDefault(); 33 | event.stopPropagation(); 34 | if (enter) document.body.removeChild(glass); 35 | }; 36 | glass.onmousedown = (event)=>{ 37 | event.preventDefault(); 38 | event.stopPropagation(); 39 | document.body.removeChild(glass); 40 | }; 41 | document.body.appendChild(glass); 42 | } 43 | 44 | function addButton(text, action) { 45 | let d = document.createElement("button"); 46 | d.className = "button"; 47 | d.innerHTML = rtext(text); 48 | d.onclick = action; 49 | btnbar.appendChild(d); 50 | } 51 | 52 | function addSpace(text, action) { 53 | let d = document.createElement("div"); 54 | d.className = "space"; 55 | btnbar.appendChild(d); 56 | } 57 | 58 | function select(e) { 59 | editor = e; 60 | repaint(); 61 | } 62 | 63 | function rtext(s) { 64 | return s. 65 | replace(/[\[]([^\]]*)[\]]/g, '$1'). 66 | replace(/{([^}]*)}/g, '$1'); 67 | } 68 | 69 | function repaint() { 70 | let w = canvas.width = innerWidth; 71 | let h = canvas.height = innerHeight; 72 | if (grid) { 73 | ctx.beginPath(); 74 | for (let y=Math.floor((0-zy)/sf/grid)*grid,yy; (yy=Math.floor((y*sf+zy)+0.5)+0.5) e.draw(ctx)); 85 | if (editor) { 86 | editor.draw(ctx); 87 | status.innerHTML = rtext(editor.text || ""); 88 | } else { 89 | status.innerHTML = rtext("[left]:Select, [middle]:Pan, [wheel]:Zoom, [⇑][wheel]:Undo/Redo"); 90 | } 91 | } 92 | 93 | function track(f) { 94 | function mods(event) { 95 | return (event.shiftKey ? 1 : 0) + (event.ctrlKey ? 2 : 0) + (event.altKey ? 4 : 0); 96 | } 97 | 98 | function mm(event) { 99 | event.preventDefault(); 100 | event.stopPropagation(); 101 | f(event.clientX, event.clientY, event.button, 1, mods(event)); 102 | } 103 | function mu(event) { 104 | event.preventDefault(); 105 | event.stopPropagation(); 106 | f(event.clientX, event.clientY, event.button, 2, mods(event)); 107 | document.removeEventListener("mousemove", mm); 108 | document.removeEventListener("mouseup", mu); 109 | } 110 | document.addEventListener("mousemove", mm); 111 | document.addEventListener("mouseup", mu); 112 | } 113 | 114 | track.SHIFT = 1; 115 | track.CTRL = 2; 116 | track.ALT = 4 117 | 118 | document.oncontextmenu = (event) => { 119 | event.preventDefault(); 120 | event.stopPropagation(); 121 | }; 122 | 123 | canvas.onmousedown = (event) => { 124 | event.preventDefault(); 125 | event.stopPropagation(); 126 | if (event.button === 1) { 127 | let xx = event.clientX, yy = event.clientY; 128 | track((x, y) => { 129 | zx += x - xx; zy += y - yy; 130 | xx = x; yy = y; 131 | repaint(); 132 | }); 133 | } else { 134 | if (editor && editor.hit(event.x, event.y, event.button)) return; 135 | editor = undefined; 136 | invalidate(); 137 | for (let i=entities.length-1; i>=0; i--) { 138 | if (entities[i].hit && entities[i].hit(event.x, event.y, event.button)) return; 139 | } 140 | if (event.button === 0) { 141 | let x0 = event.x, y0 = event.y, x1 = x0, y1 = y0; 142 | editor = { 143 | draw(ctx) { 144 | ctx.beginPath(); 145 | ctx.moveTo(x0, y0); ctx.lineTo(x1, y0); ctx.lineTo(x1, y1); ctx.lineTo(x0, y1); 146 | ctx.closePath(); 147 | ctx.strokeStyle = "#F00"; 148 | ctx.lineWidth = 1; 149 | ctx.stroke(); 150 | ctx.fillStyle = "rgba(255, 0, 0, 0.125)"; 151 | ctx.fill(); 152 | } 153 | }; 154 | track((x, y, b, phase) => { 155 | x1 = x; y1 = y; 156 | if (phase === 2) { 157 | editor = undefined; 158 | let oe=entities, xe = [], ne = [], rr = undefined, 159 | xa = (Math.min(x0, x1)-zx)/sf, xb = (Math.max(x0, x1)-zx)/sf, 160 | ya = (Math.min(y0, y1)-zy)/sf, yb = (Math.max(y0, y1)-zy)/sf; 161 | entities.forEach(e => { 162 | let b = e.bbox(); 163 | if (b.x0 >= xa && b.y0 >= ya && b.x1 <= xb && b.y1 <= yb) { 164 | if (rr === undefined) { 165 | rr = b; 166 | } else { 167 | rr.x0 = Math.min(rr.x0, b.x0); 168 | rr.y0 = Math.min(rr.y0, b.y0); 169 | rr.x1 = Math.max(rr.x1, b.x1); 170 | rr.y1 = Math.max(rr.y1, b.y1); 171 | } 172 | xe.push(e); 173 | } else { 174 | ne.push(e); 175 | } 176 | }); 177 | if (xe.length) { 178 | ne.push(new Group(rr, xe)); 179 | ur_begin("Group creation"); 180 | ur_add({undo:()=>{ entities=oe; }, redo:()=>{ entities=ne; }}); 181 | ur_end(); 182 | editor = new GroupEditor(ne[ne.length-1]); 183 | } 184 | } 185 | repaint(); 186 | }); 187 | } else if (event.button === 2) { 188 | popup(event.clientX, event.clientY, 189 | "Draw", 190 | {text: "circle", action: draw_circle}, 191 | {text: "curve", action: draw_curve}, 192 | {text: "polygon", action: draw_polygon}, 193 | {text: "text", action: draw_text}, 194 | "Grid", 195 | {text: "10", action: ()=>{ grid=10; invalidate(); }}, 196 | {text: "20", action: ()=>{ grid=20; invalidate(); }}, 197 | {text: "50", action: ()=>{ grid=50; invalidate(); }}, 198 | {text: "off", action: ()=>{ grid=undefined; invalidate(); }} 199 | ); 200 | } 201 | } 202 | }; 203 | 204 | function editPopup(e, x, y, ...extra) { 205 | select(e.editor()); 206 | popup(x, y, 207 | "Edit", 208 | {text: "{delete} delete", action: ()=>{ delete_entity(e); }}, 209 | {text: "{add_circle} clone", action: ()=>{ clone_entity(e); }}, 210 | {text: "{arrow_upward} up", action: ()=>{ updown_entity(e, 1); }}, 211 | {text: "{arrow_downward} down", action: ()=>{ updown_entity(e, -1); }}, 212 | {text: "{vertical_align_top} top", action: ()=>{ updown_entity(e, Infinity); }}, 213 | {text: "{vertical_align_bottom} bottom", action: ()=>{ updown_entity(e, -Infinity); }}, 214 | ...extra); 215 | } 216 | 217 | canvas.addEventListener("wheel", (event) => { 218 | event.preventDefault(); 219 | event.stopPropagation(); 220 | if (event.shiftKey) { 221 | let h = event.deltaY; 222 | if (h < 0) undo(); 223 | if (h > 0) redo(); 224 | editor = undefined; 225 | } else { 226 | let p = rmap(event.clientX, event.clientY); 227 | sf = Math.max(0.001, Math.min(1000, sf * Math.exp(-event.deltaY*[1, 10, 100][event.deltaMode]/300))); 228 | zx = event.clientX - p.x*sf; 229 | zy = event.clientY - p.y*sf; 230 | repaint(); 231 | } 232 | }); 233 | 234 | document.onkeydown = (event)=>{ 235 | if (editor && editor.key) { 236 | event.preventDefault(); 237 | event.stopPropagation(); 238 | editor.key(event.key); 239 | invalidate(); 240 | return false; 241 | } 242 | return true; 243 | }; 244 | 245 | function drag(e) { 246 | let first = true, p; 247 | select(e.editor()); 248 | track((x, y, b)=>{ 249 | let np = rmap(x, y); 250 | if (first) { 251 | p = np; 252 | } else { 253 | undo(); 254 | } 255 | ur_begin("Group translate"); 256 | e.transform(pt=>({x:pt.x+np.x-p.x, y:pt.y+np.y-p.y})); 257 | ur_end(); 258 | first = false; 259 | }); 260 | } 261 | 262 | setInterval(()=>{ 263 | let sz = innerWidth + "/" + innerHeight; 264 | if (sz !== csz) { 265 | csz = sz; 266 | repaint(); 267 | } 268 | }, 10); 269 | 270 | function invalidate() { 271 | if (csz) { 272 | csz = undefined; 273 | setTimeout(()=>{ 274 | csz = innerWidth + "/" + innerHeight; 275 | repaint(); 276 | }, 0); 277 | } 278 | } 279 | --------------------------------------------------------------------------------