Almost works

- Image is created in a worker :D
- Dragging is glitchy on mobile
- Share throws a PermissionDenied error but it's in the same callback as an onclick handler idks
This commit is contained in:
MindfulMinun
2020-09-12 01:05:00 -05:00
parent 1751d9bc22
commit 089515e4f5
5 changed files with 331 additions and 41 deletions
+13 -5
View File
@@ -45,21 +45,29 @@
<!-- Templates -->
<template id="template__welcome">
<div class="welcome">
<div class="welcome--blossom">
<div class="welcome--blossom--box">
<img class="affix" src="/static/icons/sakura.svg" alt="" title="Sakura" draggable="false">
</div>
</div>
<div class="welcome--text">
<h1>Sakura</h1>
<p class="welcome--subtitle">Pink manga aesthetic</p>
<p class="no-js-hide">Drag and drop an image anywhere or <button class="link-btn">choose one from your device</button></p>
</div>
<button class="welcome--blossom" title="Add an image">
<div class="welcome--blossom--box">
<img class="affix" src="/static/icons/sakura.svg" alt="" title="Sakura" draggable="false">
</div>
</button>
</div>
</template>
<template id="template__editor">
<div class="editor">
<div class="cbox affix"></div>
<div class="drawer">
<div class="controls">
<input type="color" value="#F1278C">
<input type="color" value="#FCD0E5">
<div class="flex-grow"></div>
<button>Download image</button>
</div>
</div>
</div>
</template>
<input type="file" id="input-file" hidden>
+127 -32
View File
@@ -1,6 +1,7 @@
const Sakura = {
/** @param {File[]} files */
fileDrop(files) {
if (Sakura.image.src !== '') return
const file = files.find(file => file.type.startsWith('image/'))
if (!file) {
return alert("Be sure to drop a file your browser supports :)")
@@ -10,13 +11,22 @@ const Sakura = {
// this.loadView('loading')
this.image.onload = () => {
this.ctx.canvas.height = this.image.naturalHeight
this.ctx.canvas.width = this.image.naturalWidth
this.ctx.drawImage(this.image, 0, 0)
this.mirror.height = this.image.naturalHeight
this.mirror.width = this.image.naturalWidth
this.worker.postMessage({
"<3": "Alone together",
file
})
URL.revokeObjectURL(this.image.src)
this.image.title = file.name
this.loadView('editor')
}
this.image.onerror = () => {
alert("Sorry, but this image is in a format I can't understand.")
this.image.onload = () => {}
this.image.onerror = () => {}
this.image.src = ''
}
},
/** @type {{[key: string]: ((app: Sakura) => void)[]}} */
@@ -38,7 +48,9 @@ const Sakura = {
/** @ts-ignore @type {HTMLInputElement} */
fileElement: document.getElementById('input-file'),
ctx: document.createElement('canvas').getContext('2d'),
// ctx: document.createElement('canvas').getContext('2d'),
mirror: document.createElement('canvas'),
worker: new Worker('/static/scripts/worker.js', { type: "module" }),
image: new Image(),
/**
@@ -59,7 +71,7 @@ const Sakura = {
}
}
}
// @ts-ignore
// @ts-expect-error
globalThis['Sakura'] = Sakura
// Handle file selects
@@ -78,20 +90,43 @@ window.addEventListener('drop', async ev => {
// Prevent the browser from replacing the document when a file's dropped over it
document.addEventListener('dragover', ev => ev.preventDefault())
// Worker callbacks
Sakura.worker.addEventListener('message', ev => {
switch (ev.data['<3']) {
case "I wish I could make you mine":
const { title, url } = ev.data
const anchor = document.createElement('a')
anchor.setAttribute('href', url)
anchor.setAttribute('download', title)
document.body.append(anchor)
anchor.click()
document.body.removeChild(anchor)
break
}
})
// Register load callbacks
Sakura.onload('welcome', app => {
app.view.querySelector('.link-btn')
.addEventListener('click', () => app.fileElement.click())
Array.from(app.view.querySelectorAll('button'))
.forEach(
el => el.addEventListener('click', () => app.fileElement.click())
)
})
Sakura.onload('editor', app => {
app.ctx.canvas.width
/** @ts-ignore @type {HTMLDivElement} */
const canvasBox = app.view.querySelector('.cbox')
canvasBox.appendChild(app.ctx.canvas)
canvasBox.appendChild(app.mirror)
const rect = canvasBox.getBoundingClientRect()
const initialX = rect.width / 2 - app.ctx.canvas.width / 2
const initialY = rect.height / 2 - app.ctx.canvas.height / 2
const initialX = rect.width / 2 - app.image.naturalWidth / 2
const initialY = rect.height / 2 - app.image.naturalHeight / 2
const canvas = app.mirror.transferControlToOffscreen()
app.worker.postMessage({
'<3': "Sail across the oceans just to find a way to get closer to you",
canvas
}, [canvas])
canvasBox.style.setProperty('--x', `${initialX}px`)
canvasBox.style.setProperty('--y', `${initialY}px`)
@@ -101,15 +136,58 @@ Sakura.onload('editor', app => {
rect,
image: app.image
})
/** @ts-ignore @type {HTMLInputElement[]} */
const [dark, light] = app.view.querySelectorAll('input[type=color]')
// Add event listeners
;[dark, light].forEach(el => {
el.addEventListener('change', () => {
app.worker.postMessage({
'<3': "I miss you, I need you, I love you.",
'colors': [parseInt(dark.value.slice(1), 16), parseInt(light.value.slice(1), 16)]
})
})
})
const downloadBtn = app.view.querySelector('button')
downloadBtn.addEventListener('click', () => {
app.worker.postMessage({
'<3': "Why don't we fall in love?"
})
})
if (navigator.share) {
const shareBtn = document.createElement('button')
shareBtn.textContent = 'Share'
shareBtn.addEventListener('click', () => {
Sakura.mirror.toBlob(blob => {
const file = new File([blob], Sakura.image.title)
console.log(file)
navigator.share({
// @ts-expect-error -- VS doesn't know about the new Files parameter
files: [file],
text: '🌸'
}).catch(reason => {
console.log(reason)
alert("Dang, your device didn't let me do that.")
})
})
})
downloadBtn.parentElement.append(shareBtn)
}
})
/**
* Adds a bunch of event listeners that are responsible for ~glitchy~ touch interactions
* @param {{canvasBox: HTMLDivElement, rect: DOMRect, image: HTMLImageElement}} any
*/
function touchManip({canvasBox, rect, image}) {
const SCROLL_SCALE = 480
const PINCH_SCALE = 300
const PINCH_SCALE = 600
const PADDING_BOX = 64
// Movement event listeners
@@ -146,30 +224,31 @@ function touchManip({canvasBox, rect, image}) {
canvasBox.addEventListener('wheel', ev => pointer.aggregateScroll -= ev.deltaY / SCROLL_SCALE, { passive: true })
canvasBox.addEventListener('mousemove', pointer.onPointerMove)
canvasBox.addEventListener('touchmove', ev => {
if (ev.targetTouches.length === 1) {
const left = ev.targetTouches[0]
const right = ev.targetTouches[1]
if (!right) {
pointer.multitouch = false
pointer.onPointerMove({
x: ev.targetTouches[0].clientX,
y: ev.targetTouches[0].clientY
x: left.clientX,
y: left.clientY
})
} else {
const [left, right] = ev.targetTouches
const midpoint = {
x: (left.clientX + right.clientX) / 2,
y: (left.clientY + right.clientY) / 2
}
const left = ev.targetTouches[0]
const right = ev.targetTouches[1]
const x = (left.clientX + right.clientX) / 2
const y = (left.clientY + right.clientY) / 2
const size = Math.hypot(right.clientX - left.clientX, right.clientY - left.clientY)
// Ignore this touch since it's like start of the second touch
if (!pointer.multitouch) {
pointer.lastX = midpoint.x
pointer.lastY = midpoint.y
pointer.lastX = x
pointer.lastY = y
pointer.lastSize = size
pointer.multitouch = true
}
// Scale by the position
pointer.aggregateScroll += (size - pointer.lastSize) / PINCH_SCALE
pointer.lastSize = size
pointer.onPointerMove(midpoint)
pointer.onPointerMove({x, y})
}
}, { passive: true })
@@ -185,20 +264,28 @@ function touchManip({canvasBox, rect, image}) {
canvasBox.addEventListener('mouseup', () => pointer.isPanning = false)
canvasBox.addEventListener('mouseleave', () => pointer.isPanning = false)
requestAnimationFrame(function _rAF() {
if (!canvasBox.isConnected) return
requestAnimationFrame(_rAF)
// IDK why this is janky
// I've run the profiler in Chrome several times, it looks like this callback's hitting 60fps
// but Chrome refuses to paint no more than about 5fps on my Pixel. Please uncomment the lines and try this for yourself
// let prev = performance.now()
function update() {
// if (prev) console.log(1000 / (performance.now() - prev))
// prev = performance.now()
const computed = getComputedStyle(canvasBox)
// Scroll
const parsedScale = parseFloat(computed.getPropertyValue('--scale') || '0')
const parsedScale = +computed.getPropertyValue('--scale')
// const parsedScale = +(canvasBox.dataset.scale || '')
// .1 <= scale <= 20
const scale = Math.max(.1, Math.min(20, parsedScale + pointer.aggregateScroll))
canvasBox.style.setProperty('--scale', '' + scale)
// Position
const parsedX = parseFloat(computed.getPropertyValue('--x') || '0px')
const parsedY = parseFloat(computed.getPropertyValue('--y') || '0px')
// const parsedX = +(canvasBox.dataset.x || '')
// const parsedY = +(canvasBox.dataset.y || '')
const parsedX = +computed.getPropertyValue('--x').slice(0, -2)
const parsedY = +computed.getPropertyValue('--y').slice(0, -2)
const x = Math.max(
-image.width + PADDING_BOX,
@@ -211,11 +298,19 @@ function touchManip({canvasBox, rect, image}) {
canvasBox.style.setProperty('--x', `${x}px`)
canvasBox.style.setProperty('--y', `${y}px`)
// canvasBox.setAttribute('style', `transform: translate(${x}px, ${y}px) scale(${scale})`)
// canvasBox.dataset.x = '' + x
// canvasBox.dataset.y = '' + y
// canvasBox.dataset.scale = '' + scale
pointer.aggregateX = 0
pointer.aggregateY = 0
pointer.aggregateScroll = 0
})
requestAnimationFrame(update)
}
requestAnimationFrame(update)
}
// Load the initial view
Sakura.loadView('welcome')
+80
View File
@@ -0,0 +1,80 @@
/**
* @param {number} color1
* @param {number} color2
*/
export function imageTwoTone(color1, color2, ctx) {
const dark = color1 % 0x1000000
const light = color2 % 0x1000000
pixelLevelFilter(ctx, (r, g, b, a) => {
const avg = (r + g + b) / 3 / 255
r = Math.round(lerp(
getChannel('red', dark),
getChannel('red', light),
avg
))
g = Math.round(lerp(
getChannel('green', dark),
getChannel('green', light),
avg
))
b = Math.round(lerp(
getChannel('blue', dark),
getChannel('blue', light),
avg
))
return [r, g, b, a]
})
}
/**
* @param {CanvasRenderingContext2D} ctx
* @param {(r: number, g: number, b: number, a: number) => [number, number, number, number]} fn
*/
function pixelLevelFilter(ctx, fn) {
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height)
const data = imageData.data
for (let i = 0; i < data.length; i += 4) {
const [r, g, b, a] = fn(
data[i + 0], // Red
data[i + 1], // Green
data[i + 2], // Blue
data[i + 3] // Alpha
)
data[i + 0] = r
data[i + 1] = g
data[i + 2] = b
data[i + 3] = a
}
ctx.putImageData(imageData, 0, 0)
}
function getChannel(channel, hex) {
switch (channel) {
case 'red':
return Math.floor(hex / 0x10000)
case 'green':
return Math.floor((hex % 0x10000) / 0x100)
case 'blue':
return Math.floor(hex % 0x100)
default: throw Error('Invalid channel')
}
}
/**
* Interpolates a value given two other values and a scale of one to the other
* @param {number} v0 The inital value (at x = 0)
* @param {number} v1 The final value (at x = 1)
* @param {number} x The x for the new value; the interpolation factor
* @returns {number} The interpolated value
* @example
* // A random value between 5 and 10
* lerp(5, 10, Math.random())
* @author MindfulMinun
* @since 2020-07-05
*/
function lerp(v0, v1, x) {
return (1 - x) * v0 + x * v1
}
+81
View File
@@ -0,0 +1,81 @@
import { imageTwoTone } from './blossom.js'
/** @type {File?} */
let file = null
/** @type {ImageBitmap?} */
let bitmap = null
/** @type {OffscreenCanvas?} */
let mirror = null
/** @type {OffscreenCanvasRenderingContext2D?} */
let ctx = null
self.addEventListener('message', async ev => {
switch (ev.data['<3']) {
case "Sail across the oceans just to find a way to get closer to you":
mirror = ev.data.canvas
ctx = mirror.getContext('2d')
bitmap = await createImageBitmap(file)
// Initial draw with default pinks
draw(0xF1278C, 0xFCD0E5, mirror)
break
case "Alone together":
file = ev.data.file
break
case "I miss you, I need you, I love you.":
// Draw with custom colors
const [dark, light] = ev.data.colors
draw(dark, light, mirror)
break
case "Why don't we fall in love?":
// Send over blob
mirror.convertToBlob().then(blob => {
log("Creating a blob")
const url = URL.createObjectURL(blob)
// @ts-expect-error -- VS doesn't know this is a worker :/
self.postMessage({
'<3': "I wish I could make you mine",
title: file.name,
url
})
}).catch(log)
break
case "I can't help but smile when I'm around you":
mirror.convertToBlob().then(blob => {
const temp = new File([blob], file.name)
// @ts-expect-error -- VS doesn't know this is a worker :/
self.postMessage({
'<3': "You complete me",
file: temp
})
})
break
default:
log(`Unknown message: ${ev.data['<3']}`)
}
// log(ev.data)
})
/**
* @param {number} color1
* @param {number} color2
* @param {OffscreenCanvas} canv
*/
async function draw(color1, color2, canv) {
ctx = canv.getContext('2d')
ctx.drawImage(bitmap, 0, 0)
imageTwoTone(color1, color2, ctx)
}
/**
* @param {any[]} things
*/
function log(...things) {
if (things.length === 1 && typeof things[0] === 'string') {
console.log(`[worker.js]: ${things[0]}`)
} else {
console.log('[worker.js]', ...things)
}
}
+30 -4
View File
@@ -37,6 +37,9 @@ body {
canvas {
display: block;
}
button {
cursor: pointer;
}
.container {
padding: 1em;
@@ -78,6 +81,8 @@ canvas {
.welcome {
margin: auto;
display: flex;
flex-direction: column-reverse;
width: 400px;
max-width: 100%;
text-align: center;
@@ -85,7 +90,11 @@ canvas {
}
.welcome--blossom {
width: 50%;
display: block;
margin: 0 auto;
border: 0;
background: 0;
padding: 0;
}
.welcome--blossom--box {
position: relative;
@@ -98,19 +107,36 @@ canvas {
/* Editor styles */
.editor {
margin: auto;
animation: 250ms var(--anim-std) fade-in;
}
.cbox {
/* background-color: #1e1e1e; */
position: fixed;
overflow: visible;
--x: 0;
--y: 0;
--x: 0px;
--y: 0px;
--scale: 1;
}
.cbox > canvas {
transform: translate(var(--x), var(--y)) scale(var(--scale));
/* background-color: var(--pink-hard); */
}
.drawer {
position: absolute;
flex-direction: row;
bottom: 0;
left: 0; right: 0;
padding: 1em;
background-color: #1e1e1e1e;
backdrop-filter: blur(4px);
}
.flex-grow { flex: 1; }
.controls {
display: flex;
gap: 1em;
}
.controls > input {
display: block;
}
@keyframes welcome-fade-in {