1504 lines
36 KiB
HTML
1504 lines
36 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en-us">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<title>Draw a Mandala Online</title>
|
||
<link rel="manifest" href="/manifest.json">
|
||
<link rel="apple-touch-icon" href="/assets/logo/logo-192.png">
|
||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/logo/logo-32.png">
|
||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/logo/logo-16.png">
|
||
<link rel="shortcut icon" href="/assets/logo/logo-16.png">
|
||
<style type="text/css">
|
||
html, body {
|
||
overflow: hidden;
|
||
width: 100%;
|
||
height: 100%;
|
||
margin: 0px;
|
||
background: #AAA;
|
||
|
||
touch-action: none;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
-webkit-touch-callout: none;
|
||
}
|
||
#scene {
|
||
position: absolute;
|
||
z-index: 1;
|
||
}
|
||
#drawInput {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 5;
|
||
}
|
||
#menu {
|
||
position: absolute;
|
||
z-index: 10;
|
||
width: 5em;
|
||
}
|
||
#loading-status {
|
||
display: none;
|
||
position: absolute;
|
||
z-index: 10;
|
||
top: 30px;
|
||
left: 10px;
|
||
}
|
||
#filePicker, #backgroundPicker {
|
||
visibility: hidden;
|
||
}
|
||
#pallet {
|
||
position: absolute;
|
||
top: 0px;
|
||
right: 0px;
|
||
z-index: 10;
|
||
width: 30px;
|
||
border: 1px solid black;
|
||
border-width: 0 0 1px 1px;
|
||
}
|
||
#colorPicker {
|
||
display: block;
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
}
|
||
#pallet .memory {
|
||
height: 20px;
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
.lds-dual-ring {
|
||
display: inline-block;
|
||
}
|
||
.lds-dual-ring:after {
|
||
content: " ";
|
||
display: block;
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
border: 3px solid rgb(0, 106, 255);
|
||
border-color: rgb(0, 106, 255) transparent rgb(0, 106, 255) transparent;
|
||
animation: lds-dual-ring 1.2s linear infinite;
|
||
}
|
||
@keyframes lds-dual-ring {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-VM2SHWMG7Q"></script>
|
||
<script>
|
||
window.dataLayer = window.dataLayer || [];
|
||
function gtag(){dataLayer.push(arguments);}
|
||
gtag('js', new Date());
|
||
|
||
gtag('config', 'G-VM2SHWMG7Q');
|
||
</script>
|
||
|
||
</head>
|
||
|
||
<body>
|
||
|
||
<select id="menu">
|
||
<option value="">Menu</option>
|
||
<option value="share">⤴️ Share</option>
|
||
<option value="undo">Undo</option>
|
||
<option value="save">Save</option>
|
||
<option value="load">Load</option>
|
||
<option value="clear">💣 Clear</option>
|
||
<option value="fill">Fill</option>
|
||
<option value="mirroring">Mirroring</option>
|
||
<option value="toggleFullscreen">Toggle Fullscreen</option>
|
||
<option value="about">ℹ️ About</option>
|
||
</select>
|
||
<div id="pallet">
|
||
<input type="color" id="colorPicker" />
|
||
</div>
|
||
<div id="drawInput"></div>
|
||
<canvas id="scene"></canvas>
|
||
<input type="color" id="backgroundPicker" value="#FFFFFF" />
|
||
<input type="file" id="filePicker" />
|
||
|
||
<div id="loading-status">
|
||
<div class="lds-dual-ring"></div>
|
||
</div>
|
||
|
||
<script>
|
||
'use strict';
|
||
|
||
class MyFileStorage {
|
||
constructor(dbName, storeName) {
|
||
this.dbName = dbName;
|
||
this.storeName = storeName;
|
||
this.db = null;
|
||
}
|
||
|
||
initialize() {
|
||
return new Promise((resolve, reject) => {
|
||
let openReq = window.indexedDB.open(this.dbName, 1);
|
||
|
||
openReq.onupgradeneeded = ev => {
|
||
let db = openReq.result;
|
||
db.createObjectStore(this.storeName, {keyPath: 'name'});
|
||
};
|
||
|
||
openReq.onsuccess = ev => {
|
||
this.db = openReq.result;
|
||
resolve();
|
||
};
|
||
});
|
||
}
|
||
|
||
async listFiles() {
|
||
return await this.queryFiles();
|
||
}
|
||
|
||
async readFile(name) {
|
||
let files = await this.queryFiles(name);
|
||
switch (files.length) {
|
||
case 0:
|
||
return null;
|
||
case 1:
|
||
return files[0];
|
||
default:
|
||
throw new Error('key has more than one entry');
|
||
}
|
||
}
|
||
|
||
writeFile(file) {
|
||
return new Promise((resolve, reject) => {
|
||
let tx = this.db.transaction(this.storeName, 'readwrite', {durability: 'strict'});
|
||
let store = tx.objectStore(this.storeName);
|
||
|
||
tx.oncomplete = ev => {
|
||
resolve();
|
||
};
|
||
|
||
tx.onerror = ev => {
|
||
reject(ev.target.error);
|
||
};
|
||
|
||
let putReq = store.put({
|
||
name: file.name,
|
||
file: file,
|
||
});
|
||
});
|
||
}
|
||
|
||
queryFiles(query = undefined) {
|
||
return new Promise((resolve, reject) => {
|
||
let tx = this.db.transaction(this.storeName, 'readonly', {durability: 'strict'});
|
||
let store = tx.objectStore(this.storeName);
|
||
let cursorReq = store.openCursor(query);
|
||
|
||
let files = [];
|
||
|
||
cursorReq.onsuccess = ev => {
|
||
let cursor = cursorReq.result;
|
||
if (!cursor) {
|
||
resolve(files);
|
||
} else {
|
||
let entry = cursor.value;
|
||
files.push(entry.file);
|
||
cursor.continue();
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
deleteFile(name) {
|
||
return new Promise((resolve, reject) => {
|
||
let tx = this.db.transaction(this.storeName, 'readwrite', {durability: 'strict'});
|
||
let store = tx.objectStore(this.storeName);
|
||
|
||
tx.oncomplete = ev => {
|
||
resolve();
|
||
};
|
||
|
||
tx.onerror = ev => {
|
||
reject(ev.target.error);
|
||
};
|
||
|
||
store.delete(name);
|
||
});
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<script>
|
||
'use strict';
|
||
|
||
const TAU = 2 * Math.PI;
|
||
|
||
const MAX_SNAPSHOT_HISTORY = 5;
|
||
const MAX_COLOR_HISTORY = 25;
|
||
const MAX_ZOOM = 2;
|
||
const MIN_ZOOM = 0.3;
|
||
|
||
const appFileStorage = new MyFileStorage('mandala', 'files');
|
||
|
||
const drawInput = document.getElementById('drawInput');
|
||
const menuInput = document.getElementById('menu');
|
||
const colorPicker = document.getElementById('colorPicker');
|
||
const backgroundPicker = document.getElementById('backgroundPicker');
|
||
const filePicker = document.getElementById('filePicker');
|
||
const pallet = document.getElementById('pallet');
|
||
const loadingStatus = document.getElementById('loading-status');
|
||
|
||
const primaryCanvas = document.createElement('canvas');
|
||
const primaryCtx = primaryCanvas.getContext('2d');
|
||
|
||
const displayCanvas = document.getElementById('scene');
|
||
const displayCtx = displayCanvas.getContext('2d');
|
||
|
||
let scene = {
|
||
w: 3000,
|
||
h: 3000,
|
||
|
||
viewport: {
|
||
x: 0,
|
||
y: 0,
|
||
zoom: 1,
|
||
},
|
||
|
||
drawers: new Map(),
|
||
|
||
color: 'rgb(0, 0, 0)',
|
||
colorHistory: [],
|
||
background: 'rgb(255, 255, 255)',
|
||
|
||
snapshots: [],
|
||
|
||
sections: 32,
|
||
get section() { return TAU / this.sections; }
|
||
};
|
||
|
||
// ---
|
||
|
||
async function onLoad() {
|
||
await appFileStorage.initialize();
|
||
await tryMigrateLegacyState();
|
||
|
||
let state;
|
||
try {
|
||
state = await restoreState();
|
||
} catch (ex) {
|
||
alert('Drawing state is corrupted. Resetting.');
|
||
await clearState();
|
||
state = null;
|
||
}
|
||
|
||
await initialize(state);
|
||
}
|
||
|
||
async function initialize(source) {
|
||
resetDisplaySize();
|
||
|
||
primaryCanvas.width = scene.w;
|
||
primaryCanvas.height = scene.h;
|
||
|
||
if (source) {
|
||
loadToPrimary(source);
|
||
} else {
|
||
clear();
|
||
}
|
||
|
||
centerViewport();
|
||
redrawPrimary();
|
||
}
|
||
|
||
async function onResize() {
|
||
await sleep(100); // HACK: let screen reflow for a bit
|
||
window.scrollTo(0, 0);
|
||
resetDisplaySize();
|
||
centerViewport();
|
||
redrawPrimary();
|
||
}
|
||
|
||
window.addEventListener('load', ev => {
|
||
onLoad();
|
||
});
|
||
|
||
window.addEventListener('resize', ev => {
|
||
onResize();
|
||
});
|
||
|
||
// ---
|
||
|
||
const STORAGE_FILE_WORKING = 'WORKING';
|
||
|
||
function pushSnapshot() {
|
||
let snapshot = cloneCanvas(primaryCanvas);
|
||
pushCircular(scene.snapshots, snapshot, MAX_SNAPSHOT_HISTORY);
|
||
}
|
||
|
||
async function popSnapshot() {
|
||
let snapshot = scene.snapshots.pop();
|
||
if (snapshot) {
|
||
loadToPrimary(snapshot);
|
||
redrawPrimary();
|
||
await saveState();
|
||
}
|
||
}
|
||
|
||
async function saveState() {
|
||
loadingStatus.style.display = 'block';
|
||
await setWorkingState(await primaryCanvasToBlob());
|
||
loadingStatus.style.display = 'none';
|
||
}
|
||
|
||
async function restoreState() {
|
||
let file = await appFileStorage.readFile(STORAGE_FILE_WORKING);
|
||
if (file === null) {
|
||
return null;
|
||
}
|
||
return await loadImageAsync(URL.createObjectURL(file));
|
||
}
|
||
|
||
async function clearState() {
|
||
await appFileStorage.deleteFile(STORAGE_FILE_WORKING);
|
||
}
|
||
|
||
async function setWorkingState(blob) {
|
||
let file = new File([blob], STORAGE_FILE_WORKING);
|
||
await appFileStorage.writeFile(file);
|
||
}
|
||
|
||
// ---
|
||
|
||
const STORAGE_KEY_SCENE = 'scene';
|
||
|
||
async function legacySaveState() {
|
||
let url = cloneCanvas(primaryCanvas).toDataURL();
|
||
window.localStorage.setItem(STORAGE_KEY_SCENE, url);
|
||
}
|
||
|
||
async function legacyLoadState() {
|
||
let url = window.localStorage.getItem(STORAGE_KEY_SCENE);
|
||
if (!url) return null;
|
||
return await loadImageAsync(url);
|
||
}
|
||
|
||
async function legacyClearState() {
|
||
window.localStorage.removeItem(STORAGE_KEY_SCENE);
|
||
}
|
||
|
||
async function tryMigrateLegacyState() {
|
||
let img = await legacyLoadState();
|
||
if (img) {
|
||
let blob = await canvasToBlob(cloneCanvas(img));
|
||
await setWorkingState(blob);
|
||
await legacyClearState();
|
||
}
|
||
}
|
||
|
||
// ---
|
||
|
||
async function resetDisplaySize() {
|
||
let newW = window.innerWidth;
|
||
let newH = window.innerHeight;
|
||
|
||
displayCanvas.width = newW;
|
||
displayCanvas.height = newH;
|
||
|
||
drawInput.style.width = `${newW}px`;
|
||
drawInput.style.height = `${newH}px`;
|
||
|
||
document.body.style.width = `${newW}px`;
|
||
document.body.style.height = `${newH}px`;
|
||
}
|
||
|
||
// ---
|
||
|
||
function cloneCanvas(source) {
|
||
var newCanvas = document.createElement('canvas');
|
||
var context = newCanvas.getContext('2d');
|
||
|
||
newCanvas.width = source.width;
|
||
newCanvas.height = source.height;
|
||
|
||
context.drawImage(source, 0, 0);
|
||
|
||
return newCanvas;
|
||
}
|
||
|
||
async function openCanvasAsURL() {
|
||
let blob = await primaryCanvasToBlob();
|
||
let url = window.URL.createObjectURL(blob);
|
||
window.open(url, '_blank');
|
||
}
|
||
|
||
async function primaryCanvasToBlob() {
|
||
return await canvasToBlob(primaryCanvas);
|
||
}
|
||
|
||
function canvasToBlob(canvas) {
|
||
return new Promise(resolve => {
|
||
canvas.toBlob(blob => {
|
||
resolve(blob);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---
|
||
|
||
function clear() {
|
||
primaryCtx.clearRect(0, 0, primaryCanvas.width, primaryCanvas.height);
|
||
displayCtx.clearRect(0, 0, displayCanvas.width, displayCanvas.height);
|
||
fillCanvas(primaryCtx, scene.background);
|
||
fillCanvas(displayCtx, scene.background);
|
||
}
|
||
|
||
function fillCanvas(ctx, color) {
|
||
ctx.save();
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||
ctx.restore();
|
||
}
|
||
|
||
function loadToPrimary(source) {
|
||
primaryCtx.drawImage(source, 0, 0, source.width, source.height, 0, 0, scene.w, scene.h);
|
||
}
|
||
|
||
function redrawPrimary() {
|
||
let dx = 0;
|
||
let dy = 0;
|
||
let dw = displayCanvas.width;
|
||
let dh = displayCanvas.height;
|
||
|
||
let sx = scene.viewport.x;
|
||
let sy = scene.viewport.y;
|
||
let sw = dw / scene.viewport.zoom;
|
||
let sh = dh / scene.viewport.zoom;
|
||
|
||
displayCtx.clearRect(0, 0, dw, dh);
|
||
displayCtx.drawImage(...getSafeRect(primaryCanvas, sx, sy, sw, sh, dx, dy, dw, dh))
|
||
}
|
||
|
||
// ---
|
||
|
||
function viewportToScene(x, y) {
|
||
let sceneX = scene.viewport.x + x / scene.viewport.zoom;
|
||
let sceneY = scene.viewport.y + y / scene.viewport.zoom;
|
||
return [sceneX, sceneY];
|
||
}
|
||
|
||
function sceneToViewport(x, y) {
|
||
let vx = (x - scene.viewport.x) * scene.viewport.zoom;
|
||
let vy = (y - scene.viewport.y) * scene.viewport.zoom;
|
||
return [vx, vy];
|
||
}
|
||
|
||
function viewportDimensions() {
|
||
let vw = displayCanvas.width;
|
||
let vh = displayCanvas.height;
|
||
return [vw, vh];
|
||
}
|
||
|
||
function centerViewport() {
|
||
let [vw, vh] = viewportDimensions();
|
||
|
||
let wz = vw / scene.w;
|
||
let hz = vh / scene.h;
|
||
let z = Math.max(wz, hz);
|
||
|
||
let dx = (scene.w - vw / z) / 2;
|
||
let dy = (scene.h - vh / z) / 2;
|
||
|
||
scene.viewport.x = dx;
|
||
scene.viewport.y = dy;
|
||
scene.viewport.zoom = z;
|
||
}
|
||
|
||
function panViewportBy(dx, dy) {
|
||
scene.viewport.x += dx / scene.viewport.zoom;
|
||
scene.viewport.y += dy / scene.viewport.zoom;
|
||
}
|
||
|
||
// this was hard...
|
||
function zoomViewportAt(x, y, dz) {
|
||
let z = scene.viewport.zoom;
|
||
let z_ = z + dz;
|
||
|
||
if ((z_ < MIN_ZOOM && dz < 0) || (z > MAX_ZOOM && dz > 0)) return;
|
||
|
||
let [vw, vh] = viewportDimensions();
|
||
|
||
let rx = x / vw;
|
||
let ry = y / vh;
|
||
|
||
let svw = vw / scene.viewport.zoom;
|
||
let svh = vh / scene.viewport.zoom;
|
||
|
||
let rz = z / z_;
|
||
|
||
let dsvw = svw * rz - svw;
|
||
let dsvh = svh * rz - svh;
|
||
|
||
let dvx = -rx * dsvw;
|
||
let dvy = -ry * dsvh;
|
||
|
||
scene.viewport.x += dvx;
|
||
scene.viewport.y += dvy;
|
||
scene.viewport.zoom += dz;
|
||
}
|
||
|
||
// ---
|
||
|
||
class Drawer {
|
||
constructor(color) {
|
||
this.points = [];
|
||
this.color = color || 'rgb(0, 0, 0)';
|
||
this.tool = new Croquis.Brush();
|
||
this.tool.setDrawFunction(drawMandalaPoint);
|
||
this.tool.setSize(10);
|
||
}
|
||
|
||
down(item) {
|
||
this.stabilizer = new Croquis.Stabilizer(
|
||
this.tool.down,
|
||
this.tool.move,
|
||
this.tool.up,
|
||
10,
|
||
0.5,
|
||
item.x,
|
||
item.y,
|
||
item.pressure
|
||
);
|
||
}
|
||
|
||
move(item) {
|
||
this.stabilizer.move(
|
||
item.x,
|
||
item.y,
|
||
item.pressure
|
||
);
|
||
}
|
||
|
||
up(item) {
|
||
this.stabilizer.up(
|
||
item.x,
|
||
item.y,
|
||
item.pressure
|
||
);
|
||
}
|
||
}
|
||
|
||
function drawMandalaPoint(x, y, size) {
|
||
let [sceneX, sceneY] = viewportToScene(x, y);
|
||
|
||
let [cx, cy] = sceneToViewport(scene.w/2, scene.h/2);
|
||
|
||
drawMandalaPointDirect(primaryCtx, scene.w/2, scene.h/2, sceneX, sceneY, size);
|
||
drawMandalaPointDirect(displayCtx, cx, cy, x, y, size * scene.viewport.zoom);
|
||
}
|
||
|
||
function drawMandalaPointDirect(ctx, cx, cy, x, y, size) {
|
||
let w = ctx.canvas.width;
|
||
let h = ctx.canvas.height;
|
||
|
||
let p = new Vec2(
|
||
x - cx,
|
||
cy - y
|
||
);
|
||
var halfSize = size * 0.5;
|
||
|
||
ctx.save();
|
||
ctx.translate(cx, cy);
|
||
|
||
for (let i = 0; i < scene.sections; ++i) {
|
||
let b = scene.section * i - p.angle() * Math.pow(-1, i);
|
||
let q = Vec2.fromRad(b).scaled(p.length());
|
||
|
||
ctx.save();
|
||
ctx.fillStyle = scene.color;
|
||
ctx.beginPath();
|
||
ctx.arc(q.x, q.y, halfSize, 0, TAU);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
// ---
|
||
|
||
function getPointerPressure(ev) {
|
||
switch (ev.pointerType) {
|
||
case 'mouse':
|
||
case 'touch':
|
||
return 0.5;
|
||
case 'pen':
|
||
return ev.pressure;
|
||
default:
|
||
return 0.5;
|
||
}
|
||
}
|
||
|
||
function readPointerEvent(ev) {
|
||
let rect = drawInput.getBoundingClientRect();
|
||
return {
|
||
x: ev.clientX - rect.left,
|
||
y: ev.clientY - rect.top,
|
||
pressure: getPointerPressure(ev)
|
||
};
|
||
}
|
||
|
||
function handleDrawDown(ev) {
|
||
pushSnapshot();
|
||
let drawer = new Drawer(colorPicker.value);
|
||
drawer.down(readPointerEvent(ev));
|
||
scene.drawers.set(ev.pointerId, drawer);
|
||
}
|
||
|
||
function handleDrawMove(ev) {
|
||
let drawer = scene.drawers.get(ev.pointerId);
|
||
if (drawer) {
|
||
let events = 'getCoalescedEvents' in ev ? ev.getCoalescedEvents() : [ev];
|
||
for (let e of events) {
|
||
drawer.move(readPointerEvent(e));
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleDrawUp(ev) {
|
||
let drawer = scene.drawers.get(ev.pointerId);
|
||
if (drawer) {
|
||
drawer.up(readPointerEvent(ev));
|
||
scene.drawers.delete(ev.pointerId);
|
||
sleep(100).then(() => saveState());
|
||
}
|
||
}
|
||
|
||
(() => {
|
||
const STATE_DRAWING = 'DRAWING';
|
||
const GESTURE_TIMEOUT = 50;
|
||
let pointerDownEvents = new Map();
|
||
let state = '';
|
||
|
||
let drawTimeout = 0;
|
||
let startZoom = 0;
|
||
let startDistance = 0;
|
||
|
||
drawInput.addEventListener('pointerdown', ev => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
|
||
pointerDownEvents.set(ev.pointerId, ev);
|
||
|
||
switch (pointerDownEvents.size) {
|
||
case 1:
|
||
drawTimeout = setTimeout(() => {
|
||
state = STATE_DRAWING;
|
||
handleDrawDown(ev);
|
||
}, GESTURE_TIMEOUT);
|
||
break;
|
||
case 2:
|
||
if (state === STATE_DRAWING) {
|
||
pointerDownEvents.delete(ev.pointerId, ev);
|
||
return;
|
||
}
|
||
|
||
clearTimeout(drawTimeout);
|
||
|
||
startZoom = scene.viewport.zoom;
|
||
startDistance = pointerPairDistance(...pointerDownEvents.values());
|
||
break;
|
||
}
|
||
});
|
||
|
||
function pointerPairCenter(pa, pb) {
|
||
// TODO relative to target rect?
|
||
let cx = (pa.clientX + pb.clientX) / 2;
|
||
let cy = (pa.clientY + pb.clientY) / 2;
|
||
return [cx, cy];
|
||
}
|
||
|
||
function pointerPairDistance(pa, pb) {
|
||
// TODO relative to target rect?
|
||
let dx = pa.clientX - pb.clientX;
|
||
let dy = pa.clientY - pb.clientY;
|
||
return Math.sqrt(dx*dx + dy*dy);
|
||
}
|
||
|
||
drawInput.addEventListener('pointermove', ev => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
|
||
switch (pointerDownEvents.size) {
|
||
case 1:
|
||
handleDrawMove(ev);
|
||
break;
|
||
case 2:
|
||
let [ax, ay] = pointerPairCenter(...pointerDownEvents.values());
|
||
|
||
pointerDownEvents.set(ev.pointerId, ev);
|
||
|
||
let [bx, by] = pointerPairCenter(...pointerDownEvents.values());
|
||
let bs = pointerPairDistance(...pointerDownEvents.values());
|
||
|
||
let dx = bx - ax;
|
||
let dy = by - ay;
|
||
let ds = bs / startDistance;
|
||
|
||
panViewportBy(-dx, -dy);
|
||
|
||
let newZoom = startZoom * ds;
|
||
let dz = newZoom - scene.viewport.zoom;
|
||
zoomViewportAt(bx, by, dz);
|
||
|
||
redrawPrimary();
|
||
break;
|
||
}
|
||
});
|
||
|
||
function handlePointerUp(ev) {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
|
||
handleDrawUp(ev);
|
||
pointerDownEvents.delete(ev.pointerId);
|
||
switch (pointerDownEvents.size) {
|
||
case 0:
|
||
state = '';
|
||
break;
|
||
}
|
||
}
|
||
|
||
drawInput.addEventListener('pointerup', ev => {
|
||
handlePointerUp(ev);
|
||
});
|
||
|
||
drawInput.addEventListener('pointerout', ev => {
|
||
handlePointerUp(ev);
|
||
});
|
||
|
||
drawInput.addEventListener('pointercancel', ev => {
|
||
handlePointerUp(ev);
|
||
});
|
||
|
||
drawInput.addEventListener('wheel', ev => {
|
||
ev.preventDefault();
|
||
|
||
if (state !== STATE_DRAWING) {
|
||
if (ev.ctrlKey) {
|
||
//scene.viewport.zoom += ev.deltaY * 0.01;
|
||
zoomViewportAt(ev.clientX, ev.clientY, -ev.deltaY * 0.01);
|
||
} else {
|
||
panViewportBy(ev.deltaX * 2, ev.deltaY * 2);
|
||
}
|
||
|
||
redrawPrimary();
|
||
}
|
||
});
|
||
|
||
window.addEventListener("gesturestart", function (e) {
|
||
e.preventDefault();
|
||
startZoom = scene.viewport.zoom;
|
||
});
|
||
|
||
window.addEventListener("gesturechange", function (e) {
|
||
e.preventDefault();
|
||
|
||
if (state !== STATE_DRAWING) {
|
||
let newZoom = startZoom * e.scale;
|
||
zoomViewportAt(e.clientX, e.clientY, newZoom - scene.viewport.zoom);
|
||
redrawPrimary();
|
||
}
|
||
})
|
||
|
||
window.addEventListener("gestureend", function (e) {
|
||
e.preventDefault();
|
||
});
|
||
|
||
drawInput.oncontextmenu = ev => {
|
||
ev.preventDefault();
|
||
};
|
||
})();
|
||
|
||
// ---
|
||
|
||
function canOpenFile() {
|
||
return !!window.showOpenFilePicker;
|
||
}
|
||
|
||
async function openFileToCanvas() {
|
||
let [fileHandle] = await window.showOpenFilePicker({
|
||
startIn: 'pictures',
|
||
multiple: false,
|
||
excludeAcceptAllOption: true,
|
||
types: [{
|
||
description: 'Images',
|
||
accept: {
|
||
'image/bmp': ['.bmp'],
|
||
'image/jpeg': ['.jpg', '.jpeg'],
|
||
'image/png': ['.png'],
|
||
'image/webp': ['.webp']
|
||
},
|
||
}],
|
||
});
|
||
|
||
let file = await fileHandle.getFile();
|
||
|
||
let url = await readFileAsDataURLAsync(file);
|
||
let img = await loadImageAsync(url);
|
||
|
||
pushSnapshot();
|
||
await initialize(img);
|
||
await saveState();
|
||
}
|
||
|
||
function canSaveFile() {
|
||
return !!window.showSaveFilePicker;
|
||
}
|
||
|
||
async function saveCanvasAsFile() {
|
||
let fileHandle = await window.showSaveFilePicker({
|
||
id: 'importImage',
|
||
suggestedName: 'Mandala.png',
|
||
types: [{
|
||
description: 'Images',
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
},
|
||
}],
|
||
});
|
||
|
||
let writable = await fileHandle.createWritable();
|
||
|
||
await writable.write(await primaryCanvasToBlob());
|
||
|
||
await writable.close();
|
||
}
|
||
|
||
// ---
|
||
|
||
const menuActions = {
|
||
toggleFullscreen() {
|
||
if (currentFullscreen()) {
|
||
exitFullscreen();
|
||
} else {
|
||
openFullscreen(document.body);
|
||
}
|
||
},
|
||
|
||
async save() {
|
||
if (canSaveFile()) {
|
||
await saveCanvasAsFile();
|
||
} else {
|
||
await openCanvasAsURL();
|
||
}
|
||
},
|
||
|
||
async load() {
|
||
if (canOpenFile()) {
|
||
await openFileToCanvas();
|
||
} else {
|
||
filePicker.click();
|
||
}
|
||
},
|
||
|
||
async clear() {
|
||
clear();
|
||
await saveState();
|
||
},
|
||
|
||
fill() {
|
||
backgroundPicker.value = '#000001';
|
||
backgroundPicker.click();
|
||
},
|
||
|
||
async undo() {
|
||
await popSnapshot();
|
||
},
|
||
|
||
mirroring() {
|
||
let input = window.prompt('Pick mirroring (even number)', scene.sections);
|
||
|
||
let value = parseInt(input, 10);
|
||
|
||
if (isNaN(value) || value <= 0 || value % 2 != 0) {
|
||
alert('Try again with an even number.');
|
||
return;
|
||
}
|
||
|
||
scene.sections = value;
|
||
},
|
||
|
||
about() {
|
||
window.open('https://github.com/Garciat/drawmandala.app', '_blank');
|
||
},
|
||
|
||
async share() {
|
||
let blob = await primaryCanvasToBlob();
|
||
let file = new File([blob], "mandala.png", {type: 'image/png'});
|
||
let files = [file];
|
||
let shareData = {
|
||
files: files,
|
||
};
|
||
if (navigator.share) {
|
||
if (navigator.canShare && !navigator.canShare({files: files})) {
|
||
alert(`Your system cannot share this file.`);
|
||
return;
|
||
}
|
||
await navigator.share(shareData);
|
||
} else {
|
||
alert(`Your system doesn't support sharing files.`);
|
||
}
|
||
}
|
||
};
|
||
|
||
menuInput.addEventListener('change', ev => {
|
||
menuActions[menuInput.value]();
|
||
menuInput.value = '';
|
||
});
|
||
|
||
backgroundPicker.addEventListener('change', ev => {
|
||
scene.background = backgroundPicker.value;
|
||
clear();
|
||
saveState();
|
||
});
|
||
|
||
filePicker.addEventListener('change', async function (ev) {
|
||
let files = ev.target.files;
|
||
let file = files[0];
|
||
|
||
if (!file.type.match('image.*')) {
|
||
alert('Only image files, please.');
|
||
return;
|
||
}
|
||
|
||
filePicker.value = null;
|
||
|
||
let url = await readFileAsDataURLAsync(file);
|
||
let img = await loadImageAsync(url);
|
||
|
||
pushSnapshot();
|
||
await initialize(img);
|
||
await saveState();
|
||
});
|
||
|
||
function readFileAsDataURLAsync(file) {
|
||
return new Promise((resolve, reject) => {
|
||
let reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function loadImageAsync(url) {
|
||
return new Promise((resolve, reject) => {
|
||
let img = new window.Image();
|
||
img.onload = () => resolve(img);
|
||
img.onerror = ev => reject(new Error('Could not load image from URL: ' + url));
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
// ---
|
||
|
||
function setColor(value) {
|
||
addColorHistory(scene.color);
|
||
colorPicker.value = value;
|
||
scene.color = value;
|
||
}
|
||
|
||
function addColorHistory(value) {
|
||
if (isInColorHistory(value)) return;
|
||
|
||
unshiftCircular(scene.colorHistory, value, MAX_COLOR_HISTORY);
|
||
|
||
renderColorHistory();
|
||
}
|
||
|
||
function isInColorHistory(value) {
|
||
return scene.colorHistory.indexOf(value) >= 0;
|
||
}
|
||
|
||
function renderColorHistory() {
|
||
for (let memory of document.querySelectorAll('#pallet .memory')) {
|
||
memory.parentNode.removeChild(memory);
|
||
}
|
||
|
||
for (let value of scene.colorHistory) {
|
||
let memory = document.createElement('div');
|
||
memory.dataset.value = value;
|
||
memory.classList.add('memory');
|
||
memory.style.background = value;
|
||
pallet.appendChild(memory);
|
||
}
|
||
}
|
||
|
||
colorPicker.addEventListener('change', ev => {
|
||
setColor(colorPicker.value);
|
||
});
|
||
|
||
pallet.addEventListener('click', ev => {
|
||
if (ev.target.classList.contains('memory')) {
|
||
setColor(ev.target.dataset.value);
|
||
}
|
||
});
|
||
|
||
window.addEventListener('load', ev => {
|
||
addColorHistory(rgbToHex(248,244,238));
|
||
addColorHistory(rgbToHex(255,228,225));
|
||
addColorHistory(rgbToHex(246,84,106));
|
||
addColorHistory(rgbToHex(0,206,209));
|
||
addColorHistory(rgbToHex(26,28,114));
|
||
addColorHistory(rgbToHex(255, 255, 255));
|
||
});
|
||
|
||
// ---
|
||
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.register('/service-worker.js');
|
||
}
|
||
|
||
function getPWADisplayMode() {
|
||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
|
||
if (document.referrer.startsWith('android-app://')) {
|
||
return 'twa';
|
||
} else if (navigator.standalone || isStandalone) {
|
||
return 'standalone';
|
||
}
|
||
return 'browser';
|
||
}
|
||
|
||
window.addEventListener('beforeinstallprompt', (event) => {
|
||
// nothing
|
||
});
|
||
|
||
window.addEventListener('appinstalled', (event) => {
|
||
gtag('event', 'pwa_install');
|
||
});
|
||
|
||
// ---
|
||
|
||
function rgbToHex(r, g, b) {
|
||
return `#${octetHex(r)}${octetHex(g)}${octetHex(b)}`;
|
||
}
|
||
|
||
function octetHex(x) {
|
||
return x.toString(16).padStart(2, '0');
|
||
}
|
||
|
||
// ---
|
||
|
||
class Vec2 {
|
||
constructor(x, y) {
|
||
this.x = x;
|
||
this.y = y;
|
||
}
|
||
|
||
atan2() {
|
||
return Math.atan2(this.y, this.x);
|
||
}
|
||
|
||
angle() {
|
||
let a = this.atan2();
|
||
return a >= 0 ? a : (TAU + a);
|
||
}
|
||
|
||
lengthSq() {
|
||
return this.x*this.x + this.y*this.y;
|
||
}
|
||
|
||
length() {
|
||
return Math.sqrt(this.lengthSq());
|
||
}
|
||
|
||
scaled(k) {
|
||
return new Vec2(k * this.x, k * this.y);
|
||
}
|
||
|
||
static fromRad(r) {
|
||
return new Vec2(Math.cos(r), Math.sin(r));
|
||
}
|
||
}
|
||
|
||
Vec2.X = new Vec2(1, 0);
|
||
Vec2.Y = new Vec2(0, 1);
|
||
|
||
// ---
|
||
|
||
function openFullscreen(elem) {
|
||
if (elem.requestFullscreen) {
|
||
elem.requestFullscreen();
|
||
} else if (elem.mozRequestFullScreen) {
|
||
elem.mozRequestFullScreen();
|
||
} else if (elem.webkitRequestFullscreen) {
|
||
elem.webkitRequestFullscreen();
|
||
} else if (elem.msRequestFullscreen) {
|
||
elem.msRequestFullscreen();
|
||
}
|
||
}
|
||
|
||
function exitFullscreen() {
|
||
if (document.exitFullscreen) {
|
||
document.exitFullscreen();
|
||
} else if (document.webkitExitFullscreen) {
|
||
document.webkitExitFullscreen();
|
||
} else if (document.mozCancelFullScreen) {
|
||
document.mozCancelFullScreen();
|
||
} else if (document.msExitFullscreen) {
|
||
document.msExitFullscreen();
|
||
}
|
||
}
|
||
|
||
function currentFullscreen() {
|
||
return (
|
||
document.fullscreenElement
|
||
|| document.webkitFullscreenElement
|
||
|| document.mozFullScreenElement
|
||
|| document.msFullScreenElement
|
||
);
|
||
}
|
||
|
||
// ---
|
||
|
||
function lastElement(xs) {
|
||
return xs[xs.length - 1];
|
||
}
|
||
|
||
function pushCircular(xs, x, max) {
|
||
xs.push(x);
|
||
while (xs.length > max) xs.shift();
|
||
}
|
||
|
||
function unshiftCircular(xs, x, max) {
|
||
xs.unshift(x);
|
||
while (xs.length > max) xs.pop();
|
||
}
|
||
|
||
// ---
|
||
|
||
function sleep(t) {
|
||
return new Promise(resolve => {
|
||
setTimeout(() => resolve(), t);
|
||
});
|
||
}
|
||
|
||
// ---
|
||
|
||
// https://gist.github.com/Kaiido/ca9c837382d89b9d0061e96181d1d862
|
||
function getSafeRect(source, sx, sy, sw, sh, dx, dy, dw, dh) {
|
||
const { width, height } = source;
|
||
|
||
if( sw < 0 ) {
|
||
sx += sw;
|
||
sw = Math.abs( sw );
|
||
}
|
||
if( sh < 0 ) {
|
||
sy += sh;
|
||
sh = Math.abs( sh );
|
||
}
|
||
if( dw < 0 ) {
|
||
dx += dw;
|
||
dw = Math.abs( dw );
|
||
}
|
||
if( dh < 0 ) {
|
||
dy += dh;
|
||
dh = Math.abs( dh );
|
||
}
|
||
const x1 = Math.max( sx, 0 );
|
||
const x2 = Math.min( sx + sw, width );
|
||
const y1 = Math.max( sy, 0 );
|
||
const y2 = Math.min( sy + sh, height );
|
||
const w_ratio = dw / sw;
|
||
const h_ratio = dh / sh;
|
||
|
||
return [
|
||
source,
|
||
x1,
|
||
y1,
|
||
x2 - x1,
|
||
y2 - y1,
|
||
sx < 0 ? dx - (sx * w_ratio) : dx,
|
||
sy < 0 ? dy - (sy * h_ratio) : dy,
|
||
(x2 - x1) * w_ratio,
|
||
(y2 - y1) * h_ratio
|
||
];
|
||
}
|
||
|
||
// ---
|
||
|
||
const Croquis = {};
|
||
|
||
Croquis.Stabilizer = function (down, move, up, level, weight,
|
||
x, y, pressure, interval) {
|
||
interval = interval || 5;
|
||
var follow = 1 - Math.min(0.95, Math.max(0, weight));
|
||
var paramTable = [];
|
||
var current = { x: x, y: y, pressure: pressure };
|
||
for (var i = 0; i < level; ++i)
|
||
paramTable.push({ x: x, y: y, pressure: pressure });
|
||
var first = paramTable[0];
|
||
var last = paramTable[paramTable.length - 1];
|
||
var upCalled = false;
|
||
if (down != null)
|
||
down(x, y, pressure);
|
||
window.setTimeout(_move, interval);
|
||
this.getParamTable = function () { //for test
|
||
return paramTable;
|
||
}
|
||
this.move = function (x, y, pressure) {
|
||
current.x = x;
|
||
current.y = y;
|
||
current.pressure = pressure;
|
||
}
|
||
this.up = function (x, y, pressure) {
|
||
current.x = x;
|
||
current.y = y;
|
||
current.pressure = pressure;
|
||
upCalled = true;
|
||
}
|
||
function dlerp(a, d, t) {
|
||
return a + d * t;
|
||
}
|
||
function _move(justCalc) {
|
||
var curr;
|
||
var prev;
|
||
var dx;
|
||
var dy;
|
||
var dp;
|
||
var delta = 0;
|
||
first.x = current.x;
|
||
first.y = current.y;
|
||
first.pressure = current.pressure;
|
||
for (var i = 1; i < paramTable.length; ++i) {
|
||
curr = paramTable[i];
|
||
prev = paramTable[i - 1];
|
||
dx = prev.x - curr.x;
|
||
dy = prev.y - curr.y;
|
||
dp = prev.pressure - curr.pressure;
|
||
delta += Math.abs(dx);
|
||
delta += Math.abs(dy);
|
||
curr.x = dlerp(curr.x, dx, follow);
|
||
curr.y = dlerp(curr.y, dy, follow);
|
||
curr.pressure = dlerp(curr.pressure, dp, follow);
|
||
}
|
||
if (justCalc)
|
||
return delta;
|
||
if (upCalled) {
|
||
while(delta > 1) {
|
||
move(last.x, last.y, last.pressure);
|
||
delta = _move(true);
|
||
}
|
||
up(last.x, last.y, last.pressure);
|
||
}
|
||
else {
|
||
move(last.x, last.y, last.pressure);
|
||
window.setTimeout(_move, interval);
|
||
}
|
||
}
|
||
};
|
||
|
||
Croquis.Brush = function () {
|
||
// math shortcut
|
||
var min = Math.min;
|
||
var max = Math.max;
|
||
var abs = Math.abs;
|
||
var sin = Math.sin;
|
||
var cos = Math.cos;
|
||
var sqrt = Math.sqrt;
|
||
var atan2 = Math.atan2;
|
||
var PI = Math.PI;
|
||
var ONE = PI + PI;
|
||
var QUARTER = PI * 0.5;
|
||
var random = Math.random;
|
||
this.setRandomFunction = function (value) {
|
||
random = value;
|
||
}
|
||
var color = '#000';
|
||
this.getColor = function () {
|
||
return color;
|
||
}
|
||
this.setColor = function (value) {
|
||
color = value;
|
||
}
|
||
var flow = 1;
|
||
this.getFlow = function() {
|
||
return flow;
|
||
}
|
||
this.setFlow = function(value) {
|
||
flow = value;
|
||
}
|
||
var size = 10;
|
||
this.getSize = function () {
|
||
return size;
|
||
}
|
||
this.setSize = function (value) {
|
||
size = (value < 1) ? 1 : value;
|
||
}
|
||
var spacing = 0.2;
|
||
this.getSpacing = function () {
|
||
return spacing;
|
||
}
|
||
this.setSpacing = function (value) {
|
||
spacing = (value < 0.01) ? 0.01 : value;
|
||
}
|
||
var toRad = PI / 180;
|
||
var toDeg = 1 / toRad;
|
||
var angle = 0; // radian unit
|
||
this.getAngle = function () { // returns degree unit
|
||
return angle * toDeg;
|
||
}
|
||
this.setAngle = function (value) {
|
||
angle = value * toRad;
|
||
}
|
||
var rotateToDirection = false;
|
||
this.getRotateToDirection = function () {
|
||
return rotateToDirection;
|
||
}
|
||
this.setRotateToDirection = function (value) {
|
||
rotateToDirection = value;
|
||
}
|
||
var normalSpread = 0;
|
||
this.getNormalSpread = function () {
|
||
return normalSpread;
|
||
}
|
||
this.setNormalSpread = function (value) {
|
||
normalSpread = value;
|
||
}
|
||
var tangentSpread = 0;
|
||
this.getTangentSpread = function () {
|
||
return tangentSpread;
|
||
}
|
||
this.setTangentSpread = function (value) {
|
||
tangentSpread = value;
|
||
}
|
||
var delta = 0;
|
||
var prevX = 0;
|
||
var prevY = 0;
|
||
var lastX = 0;
|
||
var lastY = 0;
|
||
var dir = 0;
|
||
var prevScale = 0;
|
||
var drawFunction = undefined;
|
||
this.setDrawFunction = function (f) {
|
||
drawFunction = f;
|
||
}
|
||
var reserved = null;
|
||
var dirtyRect;
|
||
function spreadRandom() {
|
||
return random() - 0.5;
|
||
}
|
||
function drawReserved() {
|
||
if (reserved != null) {
|
||
drawTo(reserved.x, reserved.y, reserved.scale);
|
||
reserved = null;
|
||
}
|
||
}
|
||
function appendDirtyRect(x, y, width, height) {
|
||
if (!(width && height))
|
||
return;
|
||
var dxw = dirtyRect.x + dirtyRect.width;
|
||
var dyh = dirtyRect.y + dirtyRect.height;
|
||
var xw = x + width;
|
||
var yh = y + height;
|
||
var minX = dirtyRect.width ? min(dirtyRect.x, x) : x;
|
||
var minY = dirtyRect.height ? min(dirtyRect.y, y) : y;
|
||
dirtyRect.x = minX;
|
||
dirtyRect.y = minY;
|
||
dirtyRect.width = max(dxw, xw) - minX;
|
||
dirtyRect.height = max(dyh, yh) - minY;
|
||
}
|
||
function drawTo(x, y, scale) {
|
||
var scaledSize = size * scale;
|
||
var nrm = dir + QUARTER;
|
||
var nr = normalSpread * scaledSize * spreadRandom();
|
||
var tr = tangentSpread * scaledSize * spreadRandom();
|
||
var ra = rotateToDirection ? angle + dir : angle;
|
||
var width = scaledSize;
|
||
var height = width;
|
||
var boundWidth = abs(height * sin(ra)) + abs(width * cos(ra));
|
||
var boundHeight = abs(width * sin(ra)) + abs(height * cos(ra));
|
||
x += Math.cos(nrm) * nr + Math.cos(dir) * tr;
|
||
y += Math.sin(nrm) * nr + Math.sin(dir) * tr;
|
||
drawFunction(x, y, width);
|
||
appendDirtyRect(x - (boundWidth * 0.5),
|
||
y - (boundHeight * 0.5),
|
||
boundWidth, boundHeight);
|
||
}
|
||
this.down = function(x, y, scale) {
|
||
dir = 0;
|
||
dirtyRect = {x: 0, y: 0, width: 0, height: 0};
|
||
if (scale > 0) {
|
||
if (rotateToDirection || normalSpread != 0 || tangentSpread != 0)
|
||
reserved = {x: x, y: y, scale: scale};
|
||
else
|
||
drawTo(x, y, scale);
|
||
}
|
||
delta = 0;
|
||
lastX = prevX = x;
|
||
lastY = prevY = y;
|
||
prevScale = scale;
|
||
}
|
||
this.move = function(x, y, scale) {
|
||
if (scale <= 0) {
|
||
delta = 0;
|
||
prevX = x;
|
||
prevY = y;
|
||
prevScale = scale;
|
||
return;
|
||
}
|
||
var dx = x - prevX;
|
||
var dy = y - prevY;
|
||
var ds = scale - prevScale;
|
||
var d = sqrt(dx * dx + dy * dy);
|
||
prevX = x;
|
||
prevY = y;
|
||
delta += d;
|
||
var midScale = (prevScale + scale) * 0.5;
|
||
var drawSpacing = size * spacing * midScale;
|
||
var ldx = x - lastX;
|
||
var ldy = y - lastY;
|
||
var ld = sqrt(ldx * ldx + ldy * ldy);
|
||
dir = atan2(ldy, ldx);
|
||
if (ldx || ldy)
|
||
drawReserved();
|
||
if (drawSpacing < 0.5)
|
||
drawSpacing = 0.5;
|
||
if (delta < drawSpacing) {
|
||
prevScale = scale;
|
||
return;
|
||
}
|
||
var scaleSpacing = ds * (drawSpacing / delta);
|
||
if (ld < drawSpacing) {
|
||
lastX = x;
|
||
lastY = y;
|
||
drawTo(lastX, lastY, scale);
|
||
delta -= drawSpacing;
|
||
} else {
|
||
while(delta >= drawSpacing) {
|
||
ldx = x - lastX;
|
||
ldy = y - lastY;
|
||
var tx = cos(dir);
|
||
var ty = sin(dir);
|
||
lastX += tx * drawSpacing;
|
||
lastY += ty * drawSpacing;
|
||
prevScale += scaleSpacing;
|
||
drawTo(lastX, lastY, prevScale);
|
||
delta -= drawSpacing;
|
||
}
|
||
}
|
||
prevScale = scale;
|
||
}
|
||
this.up = function (x, y, scale) {
|
||
dir = atan2(y - lastY, x - lastX);
|
||
drawReserved();
|
||
return dirtyRect;
|
||
}
|
||
};
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|