Skip to content

Instantly share code, notes, and snippets.

@Sphinxxxx
Last active December 6, 2018 20:08
Show Gist options
  • Save Sphinxxxx/e8100d76e820fc26adc91a3614f33503 to your computer and use it in GitHub Desktop.
Save Sphinxxxx/e8100d76e820fc26adc91a3614f33503 to your computer and use it in GitHub Desktop.
Draw on an image
<script>
window.onerror = function(msg, url, line) { alert('Error: '+msg+'\nURL: '+url+'\nLine: '+line); };
console.clear();
</script>
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/vanilla-picker@2"></script>
<script src="https://unpkg.com/@sphinxxxx/[email protected]"></script>
<script src="https://unpkg.com/drag-tracker@1"></script>
<script src="https://unpkg.com/simplify-js@1"></script>
<script src="https://unpkg.com/undo-manager@1"></script>
<section id="app">
<header>
<div class="help" :class="{show: viewState.showHelp}" @click="viewState.showHelp = !viewState.showHelp">
<div class="content">
<h1>Draw on an image</h1>
<span>
Touch or drag to draw,<br/>
pinch or scroll-wheel to zoom,<br/>
two fingers or right-click to pan.
</span>
</div>
</div>
</header>
<div id="tools">
<label id="image-input" class="tool-button">
<span>Select image</span>
<input type="file" accept="image/*" style="display:none" />
</label>
<button id="rotater" class="tool-button" @click.prevent="rotate">Rotate</button>
<button id="eraser" class="tool-button" :class="{ active: editor.erasing }" @click.prevent="editor.erasing = !editor.erasing">Erase</button>
<div id="stroke-color" class="picker_sample"></div>
<label id="stroke-width" class="tool-button">
<span>Stroke</span>
<input type="number" v-model.number="svg.stroke.width" @focus="$event.target.select()"/>
</label>
<button id="undoer" class="tool-button" @click.prevent="undo">Undo</button>
<button id="clearer" class="tool-button" @click.prevent="clear">Clear</button>
<button id="downloader" class="tool-button" @click.prevent="download">Download</button>
</div>
<div id="image">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="container">
<g id="image" :transform="`translate(${svg.img.pos})`">
<image :xlink:href="svg.img.url" :width="svg.img.size[0]" :height="svg.img.size[1]"
x="0" y="0" :transform="`rotate(${svg.img.rot * 90})`"/>
</g>
<g id="doodles" stroke-linejoin="round" stroke-linecap="round" fill="none">
<doodle v-for="(d, i) in svg.doodles" v-if="!d.isErased" :dood="d" :di="i"></doodle>
</g>
</g>
</svg>
</div>
</section>
(function() {
"use strict";
const [$, $$] = ABOUtils.DOM.selectors(),
_cl = ABOUtils.Debug.log2;
//Global state model. Can be changed from within Vue or from the outside.
const _svgState = {
img: {
url: 'https://picsum.photos/900/600?image=836',
filename: 'test.jpg',
size: [900, 600],
pos: [0, 0],
rot: 0,
},
stroke: {
width: 20,
color: 'red',
},
doodles: [],
},
_editorState = {
erasing: false,
cleared: [],
},
_undoer = new UndoManager();
let _svg, _zoomer,
_currDoodle, _eraseState;
Vue.component('doodle', {
template: '<path :d="pathData" :stroke="dood.color" :stroke-width="dood.strokeWidth" :data-index="di" />',
props: {
dood: Object,
di: Number,
},
computed: {
pathData() { return 'M' + this.dood.points; }
},
methods: {
},
});
new Vue({
el: '#app',
data: {
svg: _svgState,
editor: _editorState,
viewState: {
showHelp: false,
},
},
computed: {
},
watch: {
//If the stroke settings are changed, we probably want to stop erasing and draw a new doodle:
//https://stackoverflow.com/questions/42133894/vue-js-how-to-properly-watch-for-nested-data
'svg.stroke': {
handler: function (val, oldVal) { this.editor.erasing = false; },
deep: true,
},
},
methods: {
rotate() {
const img = this.svg.img;
console.log('Rot', img.rot);
//Rotate everything around the center of what's currently visible:
const [left, top, width, height] = _zoomer.getViewBox(),
center = [left + width/2, top + height/2];
let allDoods = this.svg.doodles;
//Also rotate the cleared doodles, in case the clearing is undone later:
_editorState.cleared.forEach(doods => allDoods = allDoods.concat(doods));
allDoods.forEach(d => {
console.log(d);
d.points = d.points.map(p => rotate90deg(p, center));
});
img.pos = rotate90deg(img.pos, center);
img.rot = (img.rot + 1) % 4;
},
undo() {
_undoer.undo();
},
clear() {
const doods = this.svg.doodles;
if(doods && doods.length) {
const doIt = () => {
this.editor.cleared.push(doods);
this.svg.doodles = [];
}
doIt();
_undoer.add({
undo() { _svgState.doodles = _editorState.cleared.pop(); },
redo: doIt,
});
}
},
download() { exportJPG(); }
},
});
function init() {
_svg = $('#image svg');
//_img = $('#image svg image');
//Avoid hiding the toolbar behind the browser address bar on mobile:
function maxHeight() {
document.body.style.maxHeight = window.innerHeight + 'px';
}
maxHeight();
setInterval(maxHeight, 1000);
/* Image to draw on */
function handleImage(result) {
loadImg(result.url, function() { actuallyHandleImage(this, result.url, result.file); });
}
function actuallyHandleImage(img, url, file) {
_svgState.img = {
url: url,
filename: file.name,
//https://stackoverflow.com/questions/16080273/doesnt-svg-support-auto-width-and-height-for-images
size: [
img.naturalWidth || img.width,
img.naturalHeight || img.height,
],
pos: [0, 0],
rot: 0,
};
_cl('img-load', _svgState.img.size);
}
ABOUtils.DOM.dropImage(document, handleImage);
ABOUtils.DOM.dropImage($('#image-input input'), handleImage);
/* Stroke color */
const pickerElm = $('#stroke-color'),
picker = new Picker({
parent: pickerElm,
popup: 'bottom',
onChange: color => {
pickerElm.style.color = _svgState.stroke.color = color.rgbaString;
},
});
picker.setColor('#f0ba');
/* Zoom/pan */
_zoomer = (function initSvgZoom() {
const imgContainer = $('#image'),
zoomer = zoomableSvg('#app svg', {
container: imgContainer,
});
//Mouse interaction: Left-click draws a doodle, so we must pan with right-click
let panStart, didPan;
function isRightButton(e) { return (e.buttons === 2) || (e.which === 3); }
imgContainer.addEventListener('mousedown', e => {
if(!isRightButton(e)) { return; }
panStart = [e.clientX, e.clientY];
didPan = false;
});
imgContainer.addEventListener('mousemove', e => {
if(!isRightButton(e)) { return; }
const panPos = [e.clientX, e.clientY],
panDiff = new zoomer.Coord(panPos[0] - panStart[0], panPos[1] - panStart[1]);
zoomer.moveViewport(panDiff);
panStart = panPos;
if(panDiff.x || panDiff.y) { didPan = true; }
});
imgContainer.addEventListener('contextmenu', e => {
if(didPan) { e.preventDefault(); }
});
return zoomer;
})();
/* Draw doodles */
(function initDraw() {
let cancelledByClick = false;
dragTracker({
container: _svg,
//propagateEvents: true,
callbackDragStart: (_, pos) => {
//Remove focus from toolbar:
if(picker.domElement) { picker.closeHandler({ type: 'mousedown' }); };
document.activeElement.blur();
if(_editorState.erasing) {
eraseStart(pos);
}
else {
doodleStart(pos);
}
},
callback: (_, pos) => {
if(_editorState.erasing) {
eraseMove(pos);
}
else {
doodleAppend(pos);
}
},
callbackDragEnd: (_, pos, start, cancelled) => {
//We do allow clicking to erase a single doodle:
if(cancelledByClick && _editorState.erasing) { cancelled = false; }
cancelledByClick = false;
_eraseState = null;
//Multi-touch (pinch-to-zoom)
//Cancel current drawing or erasing session:
if(cancelled) {
console.log('Cancelled', cancelled);
_undoer.undo();
}
else if(_editorState.erasing) {
}
else {
doodleEnd(pos);
}
},
callbackClick: () => {
cancelledByClick = true;
//TODO: Trigger a click on a random element to remove focus from the toolbar
//https://gomakethings.com/how-to-simulate-a-click-event-with-javascript/
// $$1('header').click();
},
});
})();
}
function getSvgPos(pos, doodle) {
const coord = _zoomer.vp2vb({
x: pos[0],
y: pos[1],
});
return [coord.x, coord.y];
}
function doodleStart(pos) {
const curr = _currDoodle = {
color: _svgState.stroke.color,
strokeWidth: _svgState.stroke.width,
isErased: false,
};
curr.points = [getSvgPos(pos, curr)];
const doods = _svgState.doodles;
doods.push(curr);
_undoer.add({
undo() { doods.pop(); },
redo() { doods.push(curr); }
});
}
function doodleAppend(pos) {
_currDoodle.points.push(getSvgPos(pos, _currDoodle));
}
function doodleEnd(pos) {
const curr = _currDoodle;
//http://mourner.github.io/simplify-js/
const ptsXY = curr.points.map(p => ({ x: p[0], y: p[1] }) ),
ptsSimpl = simplify(ptsXY, Math.sqrt(curr.strokeWidth/2)).map(p => [p.x, p.y]);
curr.points = ptsSimpl;
_cl('simplified', ptsXY.length, curr.points.length);
}
function erasePoint(pos) {
const svgPos = _svg.getBoundingClientRect(),
x = pos[0] + svgPos.left,
y = pos[1] + svgPos.top;
//https://stackoverflow.com/questions/3918842/how-to-find-out-the-actual-event-target-of-touchmove-javascript-event
const currHover = document.elementFromPoint(x, y);
if(currHover && (currHover.nodeName === 'path')) {
const i = currHover.dataset.index,
dood = _svgState.doodles[i];
if(dood) {
dood.isErased = true;
_eraseState.erased.push(dood);
}
}
}
function eraseStart(pos) {
const state = _eraseState = {
prevPos: pos,
erased: [],
}
_undoer.add({
undo() { state.erased.forEach(d => d.isErased = false); },
redo() { state.erased.forEach(d => d.isErased = true); }
});
erasePoint(pos);
}
function eraseMove(pos) {
const [x0, y0] = _eraseState.prevPos.map(Math.round),
[x1, y1] = pos.map(Math.round);
_eraseState.prevPos = pos;
//Use Bresenham's line algorithm to check all screen pixels we moved across since the last time.
//If we don't do this, it's easy to skip doodles when erasing fast:
//http://members.chello.at/easyfilter/bresenham.html
const dx = Math.abs(x1 - x0),
dy = -Math.abs(y1 - y0),
sx = (x0 < x1) ? 1 : -1,
sy = (y0 < y1) ? 1 : -1;
let [x, y] = [x0, y0],
err = dx + dy,
e2;
for (;;) {
erasePoint([x, y]);
if (x === x1 && y === y1) { break; }
e2 = 2 * err;
if (e2 >= dy) { err += dy; x += sx; }
if (e2 <= dx) { err += dx; y += sy; }
}
}
function rotate90deg(point, center) {
const [cx, cy] = center,
dx = cy - point[1],
dy = point[0] - cx;
const newPoint = [cx + dx, cy + dy];
return newPoint;
}
//Export JPG:
function exportJPG() {
_cl('export');
const [xMin, yMin, w, h] = findViewBox();
_cl('..svg-vb', xMin, yMin, w, h);
/* Serialize the SVG and create a URL for it */
const tmpSvg = _svg.cloneNode(true);
tmpSvg.setAttribute('viewBox', '' + [xMin, yMin, w, h]);
tmpSvg.setAttribute('width', w);
tmpSvg.setAttribute('height', h);
//The SVG's embedded source image doesn't transfer to 'img',
//so we must paint the canvas in two steps
const svgImg = tmpSvg.querySelector('image');
svgImg.parentNode.removeChild(svgImg);
const serializer = new XMLSerializer(),
svgCode = serializer.serializeToString(tmpSvg),
svgBlob = new Blob([svgCode], { type: 'image/svg+xml' }),
svgUrl = URL.createObjectURL(svgBlob);
_cl('..svg-blob', /*svgCode,*/ svgUrl);
/* Create a blank canvas, and paint the SVG on it */
const canvas = document.createElement('canvas'),
ctx = canvas.getContext('2d');
canvas.width = w;
canvas.height = h;
//https://stackoverflow.com/questions/27736288/how-to-fill-the-whole-canvas-with-specific-color
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, w, h);
function handleImg() {
const img = this;
_cl('..loaded', img.src);
//The SVG's embedded source image doesn't transfer to 'img',
//so we must paint the canvas in two steps.
//In the first step, where we only paint the source image,
//we must place it correctly within the viewBox we found above:
const step0 = (img.src !== svgUrl);
if(step0) {
//If the drawing has been rotated, we must calculate the correct position for the image
const state = _svgState.img,
x = state.pos[0] - xMin,
y = state.pos[1] - yMin;
//https://stackoverflow.com/questions/17411991/html5-canvas-rotate-image
ctx.translate(x, y);
ctx.rotate(state.rot * Math.PI/2);
ctx.drawImage(img, 0,0);
ctx.setTransform(1,0,0,1,0,0);
//We have painted the source image.
//Now load and paint the doodles on top of that:
loadImg(svgUrl, handleImg);
}
else {
//Paint the doodles..
ctx.drawImage(img, 0,0);
//..and export the JPG
const quality = .9, //JPG at 90% quality
name = _svgState.img.filename;
canvas.toBlob(b => downloadBlob(b, name), 'image/jpeg', quality);
}
}
//The SVG's embedded source image doesn't transfer to 'img',
//so we must paint the canvas in two steps
loadImg(_svgState.img.url /*svgUrl*/, handleImg);
//document.body.appendChild(tmpSvg);
//document.body.appendChild(img);
//document.body.appendChild(canvas);
}
function findViewBox() {
const extent = $('#container').getBBox();
return [extent.x, extent.y, extent.width, extent.height].map(Math.round);
}
function loadImg(url, callback) {
const img = new Image();
//Avoids error "Failed to execute 'toBlob' on 'HTMLCanvasElement': Tainted canvases may not be exported."
//https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image
img.crossOrigin = "Anonymous";
//https://stackoverflow.com/questions/28545619/javascript-which-parameters-are-there-for-the-onerror-event-with-image-objects
img.onerror = function(e) {
alert(`ERROR loading image (${url}): ${e.message}`);
}
img.onload = callback;
img.src = url;
}
function downloadBlob(blob, filename) {
_cl('download');
const url = (typeof blob === 'string') ? blob : URL.createObjectURL(blob),
link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.display = 'none';
document.body.appendChild(link);
_cl('..click');
link.click();
document.body.removeChild(link);
//Edge needs a little time before the blob goes away..
if(url !== blob) { setTimeout(() => URL.revokeObjectURL(url), 1000); }
}
window.addEventListener('load', init);
})();
//https://robots.thoughtbot.com/sasss-content-directive
@mixin mobile-header() {
@media(max-width: 420px) {
font-size: 3.8vw;
@content;
}
}
body {
margin: 0;
height: 100vh;
font-family: Georgia, sans-serif;
//background: skyblue;
h1, h2 {
margin: .5em 0;
}
h1 {
font-size: 1.3em;
//text-align: center;
}
h2 {
font-size: 1.1em;
}
button {
padding: .5em;
font: inherit;
}
input {
font: inherit;
box-sizing: border-box;
&[type="number"] {
width: 3em;
text-align: right;
}
}
}
body, #app {
display: flex;
flex-flow: column nowrap;
#app {
flex: 1 1 auto;
//margin: 1em;
margin-top: 0;
//background: lightskyblue;
@media (max-width: 600px) {
margin: 0;
}
}
}
header {
background: #444;
//width: 100vw;
overflow: hidden;
h1 {
margin-top: 0;
//color: white;
line-height: 1;
}
.help {
position: absolute;
top:0; right:0;
z-index: 99;
margin: 0;
padding: .5em 1em;
padding-right: 3em;
box-sizing: border-box;
min-width: 2.4em;
min-height: 2.6em;
overflow: hidden;
@include mobile-header() {
.content {
font-size: 1.2em;
}
}
background: yellow;
cursor: pointer;
&:not(.show) {
padding: 0;
background: transparent;
h1, span {
display: none;
}
}
&::after {
content: '?';
display: block;
position: absolute;
top: -.8em; right: -1.2em;
font-size: 1.5em;
width: 1em;
height: 1em;
line-height: 1;
text-align: center;
box-sizing: content-box;
padding: .5em .6em .2em .1em;
border-radius: 100%;
border: .5em solid yellow;
background: orange;
}
}
}
#tools, #image {
outline: 1px solid silver;
}
#tools {
display: flex;
flex-flow: row wrap;
align-items: center;
//padding-top: .5em;
//padding-left: .5em;
background: #ccc;
$size: 2.7em;
$group-spacing: .5em;
@include mobile-header() {
.picker_wrapper {
font-size: 3vw;
}
}
> * {
flex: 0 0 auto;
//margin-right: .5em;
//margin-bottom: .5em;
&:last-child {
margin-right: 0;
}
}
.tool-button {
display: inline-block;
position: relative;
width: $size;
height: $size;
padding: 0;
box-sizing: border-box;
border: none;
border-left: 1px solid silver;
//outline: none;
text-indent: $size * 2;
color: #333;
background-color: whitesmoke;
background-position: center;
background-size: contain;
background-repeat: no-repeat;
//box-shadow: inset 0 0 10px 5px rgba(gray, .2);
transition: opacity .2s;
box-sizing: border-box;
overflow: hidden;
cursor: pointer;
&:hover/*, &.active*/ {
background-color: paleturquoise;
//color: white;
}
}
#image-input {
line-height: $size;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='none' stroke='black' stroke-width='4'%3E %3Crect x='9' y='9' width='82' height='72' stroke-width='2'/%3E %3Cpath d='M10,75 l30,-30 l30,30 m-10,-10 l30,-30 M25,60 a27,27 0 1,1 50,-10'/%3E %3C/svg%3E");
}
#rotater {
margin-right: $group-spacing;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='none' stroke='black' stroke-width='12'%3E %3Cpath d='M20,70 v-60' stroke-dasharray='12'/%3E %3Cpath d='M30,80 h60'/%3E %3Cpath d='M35,20 a40,40 0 0,1 40,40 m-20,-13 20,13 13,-20'/%3E %3C/svg%3E");
}
#eraser {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='none' stroke='black' stroke-width='12'%3E %3Cpath d='M15,17 c90,-20 0,100, 70,60'/%3E%3C/svg%3E");
&.active {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='none' stroke='black' stroke-width='12'%3E %3Cpath d='M15,17 c90,-20 0,100, 70,60' stroke-dasharray='50 40 999' /%3E %3Cpath d='M37,30 l40,40 m0,-40 -40,40' /%3E %3C/svg%3E");
}
}
#stroke-color {
display: inline-block;
width: $size;
height: $size;
//.picker_sample overrides:
min-height: 0;
order: 0;
}
#stroke-width {
$pad: .2em;
width: 3.5em;
padding: $pad;
margin-right: $group-spacing;
span {
display: block;
//font-size: .5em;
color: transparent; //Hide the text
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='70'%3E%3Cpath fill='black' d='M10,30 c120,-50 240,50, 360,-30 v50 c-120,60 -240,-50, -360,-20'/%3E%3C/svg%3E") top center / contain no-repeat;
}
input {
position: absolute;
bottom: $pad; left:0; right:0;
margin: 0 auto;
}
}
#undoer {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='none' stroke='black' stroke-width='12'%3E %3Cpath d='M20,13 v30 h30 m-30,0 a35,35 0 1,1 50,44'/%3E %3C/svg%3E");
}
#clearer {
margin-right: $group-spacing;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='none' stroke='black' stroke-width='12'%3E %3Cpath d='M20,20 l60,60 M20,80 l60,-60'/%3E %3C/svg%3E");
}
#downloader {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='none' stroke='black' stroke-width='12'%3E %3Cpath d='M50,15 v45 m-25,-25 l25,25 25,-25 M10,80 h80'/%3E %3C/svg%3E");
}
}
#image {
flex: 1 1 auto;
position: relative;
min-height: 50vh;
svg {
display: block;
position: absolute;
top:0; left:0;
width: 100%;
height: 100%;
background: white;
//Must be inlined so it's included in the downloaded images
// #doodles {
// stroke-linecap: round;
// stroke-linejoin: round;
// }
}
}
pre {
flex: 0 0 auto;
height: 20em;
overflow: auto;
background: white;
color: #888;
border: 1px solid gainsboro;
}
/* Color picker overrides */
#tools .popup.popup_bottom {
& {
left: -12em;
}
.picker_arrow {
left: 11.5em;
transform: none;
&::before {
transform-origin: 100% 0;
transform: rotate(45deg);
}
&::after {
left: -70%;
width: 200%;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment