-
Notifications
You must be signed in to change notification settings - Fork 2
/
edit.html
141 lines (140 loc) · 8.44 KB
/
edit.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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
<!doctype html>
<title>SVG studio</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
<meta name="description" content="M,L,Q,C: Create a Move,Line,Quadratic,Cubic Node
arrows: move focused node (+alt:cubic node) (+ctrl:quadratic node)
delete: delete focused node
shift+[one of above] = apply to the whole shape" id=usage>
<link rel="icon" id=favicon>
<style>
@media (hover: none) {.pc{display:none !important;}}
@media (hover: hover) {.touch{display:none !important;}}
.M{--color:grey}
.L{--color:red}
.Q{--color:violet}
.C{--color:blue}
body{margin:0;--size:16px;background-color:#DDD;}
nav>input:invalid{border-color: red;}
figure{position:relative;margin:auto;max-width:calc(100vmin - 64px);border:1px solid #DDD;}
figure>.grid{display:grid;position: absolute;top: 0;bottom: 0;left: 0;right: 0;}
figure>.grid>*{box-shadow:0 0 0 1px rgba(0,0,0,.1);}
figure>input{position:absolute;background:var(--color);font-size:0;z-index:9;}
figure>input{border:1px solid transparent;width:var(--size);height:var(--size);border-radius:100%;cursor:pointer;}
figure>input:disabled{border-color:var(--color);background: none; user-select: none;}
figure>input:focus{outline:5px green solid;}
</style>
<main>
<nav style="display:flex">
<input type=file @change="name=$event.target.files[0].name;cat($event.target.files[0]).then(load)">
<button onclick="alert(usage.content)">?</button>
<button @click="layer.commands=layer.commands.concat(toCommands('M2,4L2,2L4,2L4,4'))">⬛</button>
<button @click="layer.commands=layer.commands.concat(toCommands('M4,7C0,7,0,1,4,1C8,1,8,7,4,7'))">⬤</button>
<button @click="layer.commands=[]">×</button>
<input v-model.number=width min=2 type=number style="width:3em" title=width>
<input v-model.number=height min=2 type=number style="width:3em" title=height>
<input v-model.number=level :max=layers.length-1 min=0 type=number title=layer>
<template v-for="(l, i) in layers">
<input v-model="l.color" :style="{background:l.color,opacity:i==level?1:.5}" size=4 placeholder=text-color @focus="level=i" pattern='#[a-f0-9]{6}'>
<button @click="layers.splice(i,1);level=Math.min(level,layers.length-1)" :disabled="layers.length<=1">-</button>
</template>
<button @click="layers=layers.concat({color:'#ff0000',commands:[]})">+</button>
<a v-for="i in [4,8,16,24]" style=margin-left:4px :download=name :href=img><img :src=img :width=i :height=i :title='img.length-24'></a>
</nav>
<figure class=rel>
<img :src=img width=100%>
<div v-if="height<32" class=grid :style="`grid-template:repeat(${height},1fr)/repeat(${width},1fr)`">
<span v-for="_ in width*height"></span>
</div>
<template v-for="lv in [1,2,'']">
<input :class=cmd.type :style=pos(cmd,lv) v-for="cmd,c in layer.commands.filter(x=>'x'+lv in x)" :disabled=!!lv @keydown="key(cmd,c,$event)">
</template>
</figure>
<nav class=touch style="display:grid; gap:5px; grid-template-columns: repeat(4, auto);">
<label v-for="mod in ['shift','ctrl', 'alt']">{{mod}}<input type=checkbox @mousedown.prevent=this.checked^=1 :ref=mod></label>
<button v-for="b in osk" @mousedown.prevent="kbev({ctrlKey:$refs.ctrl[0].checked,altKey:$refs.alt[0].checked,shiftKey:$refs.shift[0].checked,keyCode:b[2],key:b[1]||b[0]})" v-html=b[0]></button>
</nav>
</main>
<script type=module>
import "https://unpkg.com/vue@2";//import './vue.js';
const mime = "image/svg+xml";
const mime_uri = `data:${mime},`;
const rel = (ratio) => `calc(${100*ratio}% - calc(var(--size)/2) - 2px)`;
const cat = (file) => new Promise((then) => Object.assign(new FileReader(),{onload:e=>then(e.target.result)}).readAsText(file));
const kbev = (opt={}, type="keydown") => document.activeElement.dispatchEvent(new KeyboardEvent(type, opt));
const toPathString = (cmds) => cmds.map(({type,x1,y1,x2,y2,x,y}) => type + [x1,y1,x2,y2,x,y].filter(x=>x !== undefined)).join('');
const toCommands = (t) => {
const strs=t.match(/([MLHVQCSA]|[+-]?([0-9]*[.])?[0-9]+)/gi);
let cmds=[], prev={x:0,y:0}, poly={x:0,y:0}, old_type;
for(let used=0,i = 0; i < strs.length;i += used) {
let type = (old_type = isNaN(strs[i]) ? strs[i] : old_type); // store type before transform
i += isNaN(strs[i]) ? 1 : 0;//args start at [i] when implicit command
used = {z:0,m:2,l:2,h:1,v:1,q:4,c:6,a:7,s:4}[type.toLowerCase()];
let args = strs.slice(i, i + used).map(Number);
//if (type == 'z') {prev=poly;continue;}
// convert H/V to L and other lowercase to absolute coord
if (type == 'H')[type, args] = ['L', [args[0], prev.y]];
if (type == 'V')[type, args] = ['L', [prev.x, args[0]]];
if (type == 'h')[type, args] = ['L', [args[0] + prev.x, prev.y]];
if (type == 'v')[type, args] = ['L', [prev.x, args[0] + prev.y]];
if (type == 'l')[type, args] = ['L', [args[0] + prev.x, args[1] + prev.y]];
if (type == 'm')[type, args] = ['M', [args[0] + prev.x, args[1] + prev.y]];
if (type == 'S')[type, args] = ['Q', [args[0], args[1], args[2], args[3]]];
if (type == 's')[type, args] = ['Q', [args[0] + prev.x, args[1] + prev.y, args[2] + prev.x, args[3] + prev.y]];
if (type == 'q')[type, args] = ['Q', [args[0] + prev.x, args[1] + prev.y, args[2] + prev.x, args[3] + prev.y]];
if (type == 'c')[type, args] = ['C', [args[0] + prev.x, args[1] + prev.y, args[2] + prev.x, args[3] + prev.y, args[4] + prev.x, args[5] + prev.y]];
if (type == 'A')[type, args] = ['L', [args[5], args[6]]];
if (type == 'a')[type, args] = ['L', [args[5] + prev.x, args[6] + prev.y]];
const [x, y, x1, y1, x2, y2] = [...args.slice(-2), ...args.slice(0, -2)];
let obj = JSON.parse(JSON.stringify({type, x, y, x1, y1, x2, y2}));
cmds.push(obj);//to remove undefined props
prev=obj;
}
return cmds;
}
// toCommands(str);
const osk = [['×', 'Delete'],['m'],['l'],['q'],['c'],['◄', 'ArrowLeft', 37],['▲', 'ArrowUp', 38],['▼', 'ArrowDown', 40],['►', 'ArrowRight', 39],['🔍', 'Enter']];
const v = new Vue({
el: "main",
data() { return { osk, lv:0, name:"picon.svg", width: 8, height: 8, layers:[{color:'', commands: this.toCommands('M4,6L2,3Q4,4,6,3')}]}},
methods: {rel,cat,toPathString,toCommands,kbev,
key(cmd, c, ev) {
if(ev.key == 'Enter') {
Object.assign(cmd, JSON.parse(prompt("", JSON.stringify(cmd))||'{}'))
} if(ev.key == 'Delete') {
ev.shiftKey?this.layer.commands.splice(...this.groupRange(cmd,true)):this.layer.commands.splice(c,1);
} if(~"mlqc".indexOf(ev.key)) {
const opts = {m:{},l:{},q:{x1: cmd.x+1, y1: cmd.y+1},c:{x1: cmd.x+1, y1: cmd.y+1, x2: cmd.x-1, y2: cmd.y-1}}
this.layer.commands.splice(c+1,0,{type:ev.key.toUpperCase(), x: cmd.x+1, y: cmd.y+1,...opts[ev.key]})
} else if(ev.key.startsWith("Arrow")) {
const cmds = ev.shiftKey ? this.groupRange(cmd,/*true*/) : [cmd]
const attr = (ev.keyCode & 1 ? 'x':'y') + (ev.ctrlKey | ev.altKey*2 || '')
cmds.forEach(cmd=>attr in cmd?cmd[attr]=+cmd[attr]+(ev.keyCode>38)*2-1:0)
} else if(ev.key.startsWith("Page")) {
this.level += ev.keyCode == 33 ? 1 : -1;
} else return true //don't prevent (Tab, ctrl+r ...)
ev.preventDefault();
},
pos(cmd, n=''){return {top:this.rel(cmd['y'+n] / this.height), left:this.rel(cmd['x'+n] / this.width)}},
groupRange(cmd,range=false) {
const pos = this.layer.commands.indexOf(cmd);
for(var first = pos; first>0 && this.layer.commands[first].type!='M'; first--);
for(var last = pos; last+1<this.layer.commands.length && this.layer.commands[last+1].type!='M'; last++);
return range ? [first,last-first+1] : this.layer.commands.slice(first, last+1);
},
load(svg='test') {
const dom = (new DOMParser()).parseFromString(svg, mime);
[this.width, this.height] = [dom.documentElement.viewBox.baseVal.width, dom.documentElement.viewBox.baseVal.height];
return this.layers = [...dom.querySelectorAll('path')].map(path => ({commands:this.toCommands(path.attributes.d.value), color:path.attributes.fill?.value}));
}
},
computed: {
layer(){ return this.layers[this.level];},
level:{get(){return this.lv}, set(val){this.lv = Math.max(0, Math.min(val, this.layers.length - 1));}},
img() { return favicon.href=`${mime_uri}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${this.width} ${this.height}">`+
this.layers.map(layer=>`<path d="${this.toPathString(layer.commands)}"${layer.color?' fill="'+encodeURIComponent(layer.color)+'"':''}></path>`).join('')+
`</svg>`}
}
});
if(location.hash.length>1)v.load(decodeURIComponent(location.hash.slice(1)));
addEventListener('hashchange', ()=>v.load(decodeURIComponent(location.hash.slice(1))), false);
</script>