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:
+13
-5
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user