Wanted to create a drawing app that uses VueJS for it's interface and data management. :)
Feel free to steal it
A Pen by Lewi Hussey on CodePen.
<div id="draw"> | |
<div class="welcome-bg" v-if="popups.showWelcome"> | |
<div class="welcome"> | |
<h1 class="fade-up">Vue JS draw</h1> | |
<h2 class="fade-up"> | |
By Lewi Hussey | |
</h2> | |
<a href="//twitter.com/lewitje" target="blank" title="Lewi Hussey on Twitter" class="fade-up">@lewitje</a> | |
<span class="btn fade-up" | |
title="Close" | |
v-on:click="popups.showWelcome = false"> | |
Lets go | |
</span> | |
</div> | |
</div> | |
<div class="app-wrapper"> | |
<canvas id="canvas"> | |
</canvas> | |
<div class="cursor" id="cursor"></div> | |
<div class="controls"> | |
<div class="btn-row"> | |
<div class="history" title="history"> | |
{{ history.length }} | |
</div> | |
</div> | |
<div class="btn-row"> | |
<button type="button" | |
v-on:click="removeHistoryItem" | |
v-bind:class="{ disabled: !history.length }" title="Undo"> | |
<i class="ion ion-reply"></i> | |
</button> | |
<button type="button" | |
v-on:click="removeAllHistory" | |
v-bind:class="{ disabled: !history.length }" title="Clear all"> | |
<i class="ion ion-trash-a"></i> | |
</button> | |
</div> | |
<div class="btn-row"> | |
<button title="Brush options" | |
v-on:click="popups.showOptions = !popups.showOptions"> | |
<i class="ion ion-android-create"></i> | |
</button> | |
<div class="popup" v-if="popups.showOptions"> | |
<div class="popup-title"> | |
Options | |
</div> | |
<button title="Restrict movement vertical" | |
v-on:click="options.restrictY = !options.restrictY; options.restrictX = false" | |
v-bind:class="{ active: options.restrictY }" | |
title="Restrict vertical"> | |
<i class="ion ion-arrow-right-c"></i> | |
Restrict vertical | |
</button> | |
<button title="Restrict movement horizontal" | |
v-on:click="options.restrictX = !options.restrictX; options.restrictY = false" | |
v-bind:class="{ active: options.restrictX }" | |
title="Restrict horizontal"> | |
<i class="ion ion-arrow-up-c"></i> | |
Restrict horizontal | |
</button> | |
<button type="button" | |
v-on:click="simplify" | |
v-bind:class="{ disabled: !history.length }" title="Simplify paths"> | |
<i class="ion ion-wand"></i> | |
Simplify paths | |
</button> | |
<button type="button" | |
v-on:click="jumble" | |
v-bind:class="{ disabled: !history.length }" title="Go nutz"> | |
<i class="ion ion-shuffle"></i> | |
Go nutz | |
</button> | |
</div> | |
</div> | |
<div class="btn-row"> | |
<button title="Pick a brush size" | |
v-on:click="popups.showSize = !popups.showSize" | |
v-bind:class="{ active: popups.showSize }"> | |
<i class="ion ion-android-radio-button-on"></i> | |
<span class="size-icon"> | |
{{ size }} | |
</span> | |
</button> | |
<div class="popup" v-if="popups.showSize"> | |
<div class="popup-title"> | |
Brush size | |
</div> | |
<label v-for="sizeItem in sizes" class="size-item"> | |
<input type="radio" name="size" v-model="size" v-bind:value="sizeItem"> | |
<span class="size" | |
v-bind:style="{width: sizeItem + 'px', height: sizeItem + 'px'}" | |
v-on:click="popups.showSize = !popups.showSize"></span> | |
</label> | |
</div> | |
</div> | |
<div class="btn-row"> | |
<button title="Pick a color" | |
v-on:click="popups.showColor = !popups.showColor" | |
v-bind:class="{ active: popups.showColor }"> | |
<i class="ion ion-android-color-palette"></i> | |
<span class="color-icon" | |
v-bind:style="{backgroundColor: color}"> | |
</span> | |
</button> | |
<div class="popup" v-if="popups.showColor"> | |
<div class="popup-title"> | |
Brush color | |
</div> | |
<label v-for="colorItem in colors" class="color-item"> | |
<input type="radio" | |
name="color" | |
v-model="color" | |
v-bind:value="colorItem"> | |
<span v-bind:class="'color color-' + colorItem" | |
v-bind:style="{backgroundColor: colorItem}" | |
v-on:click="popups.showColor = !popups.showColor"></span> | |
</label> | |
</div> | |
</div> | |
<div class="btn-row"> | |
<button title="Save" | |
v-on:click="popups.showSave = !popups.showSave"> | |
<i class="ion ion-android-cloud-outline"></i> | |
</button> | |
<div class="popup" v-if="popups.showSave"> | |
<div class="popup-title"> | |
Save your design | |
</div> | |
<div class="form"> | |
<input type="text" | |
placeholder="Save name" | |
v-model="save.name"> | |
<div v-if="save.name.length < 3" class="text-faded"> | |
<i> | |
Min 3 characters | |
</i> | |
</div> | |
<span class="btn" | |
v-on:click="saveItem"> | |
Save as | |
<span class="text-faded"> | |
{{ save.name }} | |
</span> | |
</span> | |
</div> | |
<div class="saves" v-if="save.saveItems.length"> | |
<div class="popup-title"> | |
Load a save | |
</div> | |
<div class="save-item" | |
v-for="item in save.saveItems"> | |
<h3>{{ item.name }}</h3> | |
<span class="btn" | |
v-on:click="loadSave(item)">load</span> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="btn-row"> | |
<button v-on:click="popups.showWelcome = true" title="Made by Lewi"> | |
<i class="ion ion-heart"></i> | |
</button> | |
</div> | |
</div> | |
</div> | |
</div> |
var app = new Vue({ | |
el: '#draw', | |
data: { | |
history: [], | |
color: '#13c5f7', | |
popups: { | |
showColor: false, | |
showSize: false, | |
showWelcome: true, | |
showSave: false, | |
showOptions: false | |
}, | |
options: { | |
restrictY: false, | |
restrictX: false | |
}, | |
save: { | |
name: '', | |
saveItems: [] | |
}, | |
size: 12, | |
colors: [ | |
'#d4f713', | |
'#13f7ab', | |
'#13f3f7', | |
'#13c5f7', | |
'#138cf7', | |
'#1353f7', | |
'#2d13f7', | |
'#7513f7', | |
'#a713f7', | |
'#d413f7', | |
'#f713e0', | |
'#f71397', | |
'#f7135b', | |
'#f71313', | |
'#f76213', | |
'#f79413', | |
'#f7e013'], | |
sizes: [6, 12, 24, 48], | |
weights: [ 2, 4, 6 ] | |
}, | |
methods: { | |
removeHistoryItem: ()=>{ | |
app.history.splice(app.history.length-2, 1); | |
draw.redraw(); | |
}, | |
removeAllHistory: ()=>{ | |
app.history = []; | |
draw.redraw(); | |
}, | |
simplify: ()=>{ | |
var simpleHistory = []; | |
app.history.forEach((item, i)=>{ | |
if(i % 6 !== 1 || item.isDummy){ | |
simpleHistory.push(item); | |
} | |
}); | |
app.history = simpleHistory; | |
draw.redraw(); | |
}, | |
jumble: ()=>{ | |
var simpleHistory = []; | |
app.history.forEach((item, i)=>{ | |
item.r += Math.sin(i * 20) * 5; | |
}); | |
app.history = app.shuffle(app.history); | |
draw.redraw(); | |
}, | |
shuffle: (a)=>{ | |
var b = []; | |
a.forEach((item, i)=>{ | |
if(!item.isDummy){ | |
var l = b.length; | |
var r = Math.floor(l * Math.random()); | |
b.splice(r, 0, item); | |
} | |
}); | |
for(var i = 0; i < b.length; i++){ | |
if(i % 20 === 1){ | |
b.push(draw.getDummyItem()); | |
} | |
} | |
return b; | |
}, | |
saveItem: ()=>{ | |
if(app.save.name.length > 2){ | |
var historyItem = { | |
history: app.history.slice(), | |
name: app.save.name | |
}; | |
app.save.saveItems.push(historyItem); | |
app.save.name = ""; | |
} | |
}, | |
loadSave: (item)=>{ | |
app.history = item.history.slice(); | |
draw.redraw(); | |
} | |
} | |
}); | |
class Draw { | |
constructor(){ | |
this.c = document.getElementById('canvas'); | |
this.ctx = this.c.getContext('2d'); | |
this.mouseDown = false; | |
this.mouseX = 0; | |
this.mouseY = 0; | |
this.tempHistory = []; | |
this.setSize(); | |
this.listen(); | |
this.redraw(); | |
} | |
listen(){ | |
this.c.addEventListener('mousedown', (e)=>{ | |
this.mouseDown = true; | |
this.mouseX = e.offsetX; | |
this.mouseY = e.offsetY; | |
this.setDummyPoint(); | |
}); | |
this.c.addEventListener('mouseup', ()=>{ | |
if(this.mouseDown){ | |
this.setDummyPoint(); | |
} | |
this.mouseDown = false; | |
}); | |
this.c.addEventListener('mouseleave', ()=>{ | |
if(this.mouseDown){ | |
this.setDummyPoint(); | |
} | |
this.mouseDown = false; | |
}); | |
this.c.addEventListener('mousemove', (e)=>{ | |
this.moveMouse(e); | |
if(this.mouseDown){ | |
this.mouseX = this.mouseX; | |
this.mouseY = this.mouseY; | |
if(!app.options.restrictX){ | |
this.mouseX = e.offsetX; | |
} | |
if(!app.options.restrictY){ | |
this.mouseY = e.offsetY; | |
} | |
var item = { | |
isDummy: false, | |
x: this.mouseX, | |
y: this.mouseY, | |
c: app.color, | |
r: app.size | |
}; | |
app.history.push(item); | |
this.draw(item, app.history.length); | |
} | |
}); | |
window.addEventListener('resize', ()=>{ | |
this.setSize(); | |
this.redraw(); | |
}); | |
} | |
setSize(){ | |
this.c.width = window.innerWidth; | |
this.c.height = window.innerHeight - 60; | |
} | |
moveMouse(e){ | |
let x = e.offsetX; | |
let y = e.offsetY; | |
var cursor = document.getElementById('cursor'); | |
cursor.style.transform = `translate(${x - 10}px, ${y - 10}px)`; | |
} | |
getDummyItem(){ | |
var lastPoint = app.history[app.history.length-1]; | |
return { | |
isDummy: true, | |
x: lastPoint.x, | |
y: lastPoint.y, | |
c: null, | |
r: null | |
}; | |
} | |
setDummyPoint(){ | |
var item = this.getDummyItem(); | |
app.history.push(item); | |
this.draw(item, app.history.length); | |
} | |
redraw(){ | |
this.ctx.clearRect(0, 0, this.c.width, this.c.height); | |
this.drawBgDots(); | |
if(!app.history.length){ | |
return true; | |
} | |
app.history.forEach((item, i)=>{ | |
this.draw(item, i); | |
}); | |
} | |
drawBgDots(){ | |
var gridSize = 50; | |
this.ctx.fillStyle = 'rgba(0, 0, 0, .2)'; | |
for(var i = 0; i*gridSize < this.c.width; i++){ | |
for(var j = 0; j*gridSize < this.c.height; j++){ | |
if(i > 0 && j > 0){ | |
this.ctx.beginPath(); | |
this.ctx.rect(i * gridSize, j * gridSize, 2, 2); | |
this.ctx.fill(); | |
this.ctx.closePath(); | |
} | |
} | |
} | |
} | |
draw(item, i){ | |
this.ctx.lineCap = 'round'; | |
this.ctx.lineJoin="round"; | |
var prevItem = app.history[i-2]; | |
if(i < 2){ | |
return false; | |
} | |
if(!item.isDummy && !app.history[i-1].isDummy && !prevItem.isDummy){ | |
this.ctx.strokeStyle = item.c; | |
this.ctx.lineWidth = item.r; | |
this.ctx.beginPath(); | |
this.ctx.moveTo(prevItem.x, prevItem.y); | |
this.ctx.lineTo(item.x, item.y); | |
this.ctx.stroke(); | |
this.ctx.closePath(); | |
} else if (!item.isDummy) { | |
this.ctx.strokeStyle = item.c; | |
this.ctx.lineWidth = item.r; | |
this.ctx.beginPath(); | |
this.ctx.moveTo(item.x, item.y); | |
this.ctx.lineTo(item.x, item.y); | |
this.ctx.stroke(); | |
this.ctx.closePath(); | |
} | |
} | |
} | |
var draw = new Draw(); |
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.13/vue.min.js"></script> |
@import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500'); | |
$prim: rgb(0, 149, 255); | |
* { | |
box-sizing: border-box; | |
margin: 0; | |
padding: 0; | |
} | |
body { | |
background-color: black; | |
color: white; | |
} | |
body, | |
button, | |
input { | |
font-family: 'Roboto', sans-serif; | |
font-size: 14px; | |
font-weight: 400; | |
} | |
h1, | |
h2, | |
h3, | |
h4, | |
h5{ | |
font-weight: 500; | |
} | |
.text-faded { | |
opacity: .5; | |
} | |
.cursor { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 20px; | |
height: 20px; | |
border-radius: 50%; | |
border: 3px solid rgb(30, 30, 30); | |
pointer-events: none; | |
user-select: none; | |
mix-blend-mode: difference; | |
opacity: 0; | |
transition: opacity 1s; | |
} | |
canvas { | |
width: 100%; | |
height: calc(100vh - 60px); | |
background-color: white; | |
cursor: none; | |
&:hover + .cursor { | |
opacity: 1; | |
} | |
&:active + .cursor { | |
border-color: rgb(60, 60, 60); | |
} | |
} | |
.controls { | |
position: fixed; | |
z-index: 5; | |
bottom: 0; | |
left: 0; | |
width: 100%; | |
height: 60px; | |
background-color: rgb(10, 10, 10); | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
user-select: none; | |
} | |
.stat { | |
font-size: 20px; | |
margin-bottom: 15px; | |
} | |
.btn-row { | |
position: relative; | |
margin-bottom: 5px; | |
display: flex; | |
justify-content: center; | |
flex-wrap: wrap; | |
padding: 0 15px; | |
border-radius: 4px; | |
} | |
.popup { | |
position: absolute; | |
width: 300px; | |
bottom: 58px; | |
padding: 30px; | |
left: calc(50% - 150px); | |
display: flex; | |
flex-wrap: wrap; | |
justify-content: center; | |
background-color: white; | |
color: rgb(30, 30, 30); | |
border-radius: 10px 10px 0 0; | |
border: 1px solid rgb(220, 220, 220); | |
border-bottom-width: 0; | |
opacity: 0; | |
animation: popup .5s forwards cubic-bezier(.2, 2, .4, 1); | |
z-index: 2; | |
overflow: hidden; | |
max-height: 80vh; | |
overflow-y: auto; | |
.popup-title { | |
flex: 0 0 100%; | |
text-align: center; | |
font-size: 16px; | |
color: black; | |
opacity: .5; | |
margin-bottom: 10px; | |
} | |
button { | |
height: 80px; | |
width: 80px; | |
text-align: center; | |
font-size: 14px; | |
color: rgba(0, 0, 0, .4); | |
i { | |
display: block; | |
font-size: 30px; | |
margin-bottom: 5px; | |
color: rgba(0, 0, 0, .2); | |
} | |
&.disabled { | |
color: rgba(0, 0, 0, .2); | |
i { | |
color: rgba(0, 0, 0, .1); | |
} | |
} | |
&.active, | |
&:active { | |
color: rgba(0, 0, 0, .4); | |
i { | |
color: $prim; | |
} | |
} | |
} | |
} | |
@keyframes popup { | |
from { | |
opacity: 0; | |
transform: translateX(40px); | |
} | |
to { | |
opacity: 1; | |
transform: none; | |
} | |
} | |
.welcome-bg { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
z-index: 9; | |
background-color: $prim; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
} | |
.fade-up { | |
opacity: 0; | |
animation: fade-up 1s forwards cubic-bezier(.2, 2, .4, 1); | |
} | |
.btn { | |
display: inline-block; | |
margin-top: 10px; | |
padding: 10px 20px; | |
font-weight: 400; | |
font-size: 16px; | |
border-radius: 4px; | |
background-color: $prim; | |
color: white; | |
animation-delay: 1s; | |
transition: all .15s; | |
cursor: pointer; | |
&:hover { | |
background-color: lighten($prim, 10%); | |
} | |
} | |
.welcome { | |
width: 400px; | |
height: 400px; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
flex-direction: column; | |
h1.fade-up { | |
font-weight: 300; | |
font-size: 40px; | |
animation-delay: .25s; | |
} | |
h2.fade-up { | |
font-weight: 400; | |
color: rgba(255, 255, 255, .5); | |
animation-delay: .5s; | |
} | |
a.fade-up { | |
color: rgba(255, 255, 255, .5); | |
display: inline-block; | |
margin-top: 20px; | |
text-decoration: none; | |
animation-delay: .75s; | |
} | |
.btn.fade-up { | |
background-color: rgba(255, 255, 255, .2); | |
color: white; | |
margin-top: 60px; | |
&:hover { | |
background-color: rgba(255, 255, 255, .3); | |
} | |
} | |
} | |
@keyframes fade-up { | |
from { | |
transform: translateY(80px); | |
opacity: 0; | |
} | |
to { | |
transform: none; | |
opacity: 1; | |
} | |
} | |
.form { | |
flex: 0 0 100%; | |
input { | |
display: block; | |
appearance: none; | |
border: 0; | |
box-shadow: 0; | |
outline: 0; | |
background-color: rgb(240, 240, 240); | |
border-radius: 4px; | |
padding: 10px 15px; | |
width: 100%; | |
margin-bottom: 4px; | |
} | |
} | |
button { | |
appearance: none; | |
border: 0; | |
border-radius: 0; | |
box-shadow: 0; | |
width: 40px; | |
height: 60px; | |
display: inline-block; | |
background-color: transparent; | |
color: rgb(140, 140, 140); | |
font-size: 22px; | |
transition: all .15s; | |
cursor: pointer; | |
outline: 0; | |
position: relative; | |
.size-icon, | |
.color-icon { | |
position: absolute; | |
top: 10px; | |
right: 0; | |
} | |
.color-icon { | |
width: 5px; | |
height: 5px; | |
border-radius: 50%; | |
} | |
.size-icon { | |
font-size: 6px; | |
text-align: right; | |
} | |
&:hover { | |
opacity: .8; | |
} | |
&:active, | |
&.active{ | |
color: white; | |
} | |
&.disabled { | |
color: rgb(50, 50, 50); | |
cursor: not-allowed; | |
} | |
} | |
.history { | |
width: 30px; | |
height: 30px; | |
background-color: rgb(30, 30, 30); | |
border-radius: 50%; | |
text-align: center; | |
line-height: 30px; | |
font-size: 12px; | |
overflow: hidden; | |
color: rgb(140, 140, 140); | |
} | |
.color-item { | |
position: relative; | |
display: inline-block; | |
cursor: pointer; | |
width: 60px; | |
height: 60px; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
input { | |
position: absolute; | |
opacity: 0; | |
top: 0; | |
left: 0; | |
width: 0; | |
height: 0; | |
} | |
input:checked + .color { | |
opacity: 1; | |
border: 2px solid $prim; | |
} | |
.color { | |
display: block; | |
width: 30px; | |
height: 30px; | |
background-color: white; | |
border-radius: 50%; | |
&:hover { | |
opacity: .8; | |
} | |
} | |
} | |
@keyframes pulsate { | |
0%, | |
100% { | |
transform: none; | |
} | |
50% { | |
transform: scale(1.15); | |
} | |
} | |
.size-item { | |
width: 40px; | |
height: 60px; | |
display: inline-flex; | |
position: relative; | |
justify-content: center; | |
align-items: center; | |
vertical-align: top; | |
cursor: pointer; | |
input { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 0; | |
height: 0; | |
opacity: 0; | |
} | |
.size { | |
background-color: rgb(140, 140, 140); | |
display: inline-block; | |
border-radius: 50%; | |
transition: all .15s; | |
transform: translate(-50%, -50%) scale(.6); | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
&:hover { | |
opacity: .8; | |
} | |
} | |
input:checked + .size { | |
background-color: $prim; | |
} | |
} | |
.saves { | |
flex: 0 0 calc(100% + 60px); | |
margin: 30px -30px -30px; | |
padding: 30px; | |
background-color: rgb(240, 240, 240); | |
max-height: 250px; | |
overflow-y: auto; | |
.save-item { | |
width: 100%; | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
} | |
} |
<link href="https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet" /> |
Wanted to create a drawing app that uses VueJS for it's interface and data management. :)
Feel free to steal it
A Pen by Lewi Hussey on CodePen.