- When calling navigator.share, Files have to have a MIME type - Oh my fucking god I forgot to add will-change: transform - The whole image can now be seen at start. No more having to zoom out :D
313 lines
10 KiB
JavaScript
313 lines
10 KiB
JavaScript
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 :)")
|
|
}
|
|
|
|
this.image.src = URL.createObjectURL(file)
|
|
// this.loadView('loading')
|
|
|
|
this.image.onload = () => {
|
|
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)[]}} */
|
|
_loadCallbacks: {},
|
|
|
|
/** @param {string} id */
|
|
loadView(id) {
|
|
/** @ts-ignore @type {HTMLTemplateElement} */
|
|
const temp = document.getElementById(`template__${id}`)
|
|
const clone = temp.content.cloneNode(true)
|
|
this.view.innerHTML = ''
|
|
this.view.appendChild(clone)
|
|
this.returnCall(id)
|
|
return Promise.resolve(this)
|
|
},
|
|
|
|
view: document.getElementById('app'),
|
|
|
|
/** @ts-ignore @type {HTMLInputElement} */
|
|
fileElement: document.getElementById('input-file'),
|
|
|
|
// ctx: document.createElement('canvas').getContext('2d'),
|
|
mirror: document.createElement('canvas'),
|
|
worker: new Worker('/static/scripts/worker.js', { type: "module" }),
|
|
image: new Image(),
|
|
|
|
/**
|
|
* Calls callback after the id has loaded. May be called more than once
|
|
* @param {string} id
|
|
* @param {(app: Sakura) => void} cb
|
|
*/
|
|
onload(id, cb) {
|
|
this._loadCallbacks[id] = this._loadCallbacks[id] || []
|
|
this._loadCallbacks[id].push(cb)
|
|
},
|
|
|
|
/** @param {string} id */
|
|
returnCall(id) {
|
|
const cbs = this._loadCallbacks[id]
|
|
if (cbs) {
|
|
cbs.forEach(cb => cb.call(this, this))
|
|
}
|
|
}
|
|
}
|
|
// @ts-expect-error
|
|
globalThis['Sakura'] = Sakura
|
|
|
|
// Handle file selects
|
|
Sakura.fileElement.addEventListener('change', function () {
|
|
if (this.files) {
|
|
Sakura.fileDrop([...this.files])
|
|
}
|
|
})
|
|
|
|
// Listen for file drops
|
|
window.addEventListener('drop', async ev => {
|
|
ev.preventDefault()
|
|
Sakura.fileDrop([...ev.dataTransfer.files])
|
|
})
|
|
|
|
// 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 => {
|
|
Array.from(app.view.querySelectorAll('button'))
|
|
.forEach(
|
|
el => el.addEventListener('click', () => app.fileElement.click())
|
|
)
|
|
})
|
|
|
|
Sakura.onload('editor', app => {
|
|
/** @ts-ignore @type {HTMLDivElement} */
|
|
const canvasBox = app.view.querySelector('.cbox')
|
|
canvasBox.appendChild(app.mirror)
|
|
const rect = canvasBox.getBoundingClientRect()
|
|
|
|
// Scale so the whole image can be seen at the start
|
|
const initialScale = Math.min(
|
|
window.innerWidth / app.image.naturalWidth,
|
|
window.innerHeight / app.image.naturalHeight,
|
|
1
|
|
)
|
|
const initialX = (rect.width / 2) - (app.image.naturalWidth * initialScale / 2)
|
|
const initialY = (rect.height / 2) - (app.image.naturalHeight * initialScale / 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`)
|
|
canvasBox.style.setProperty('--scale', `${initialScale}`)
|
|
|
|
touchManip({
|
|
canvasBox,
|
|
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, { type: blob.type })
|
|
console.log(file)
|
|
navigator.share({
|
|
// @ts-expect-error -- VS doesn't know about the new Files parameter
|
|
files: [file],
|
|
text: '🌸'
|
|
}).catch(reason => {
|
|
if (("" + reason).endsWith('Share canceled')) return
|
|
|
|
alert("Oops, 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 = 500
|
|
const PADDING_BOX = 64
|
|
|
|
// Movement event listeners
|
|
// Mouse events fire way too often
|
|
// getComputedStyle too expensive to call on each of these events
|
|
// Instead, accumulate the scrolled distance and update once per frame
|
|
|
|
const pointer = {
|
|
isPanning: false,
|
|
multitouch: false,
|
|
lastX: 0,
|
|
lastY: 0,
|
|
lastSize: 0,
|
|
aggregateX: 0,
|
|
aggregateY: 0,
|
|
aggregateScroll: 0,
|
|
/** @param {{x: number, y: number}} a */
|
|
onPointerMove: ({x, y}) => {
|
|
if (pointer.isPanning) {
|
|
pointer.aggregateX += (x - pointer.lastX)
|
|
pointer.aggregateY += (y - pointer.lastY)
|
|
pointer.lastX = x
|
|
pointer.lastY = y
|
|
}
|
|
},
|
|
/** @param {{x: number, y: number}} a */
|
|
onPointerDown: ({x, y}) => {
|
|
pointer.isPanning = true
|
|
pointer.lastX = x
|
|
pointer.lastY = y
|
|
},
|
|
}
|
|
|
|
canvasBox.addEventListener('wheel', ev => pointer.aggregateScroll -= ev.deltaY / SCROLL_SCALE, { passive: true })
|
|
canvasBox.addEventListener('mousemove', pointer.onPointerMove, { passive: true })
|
|
canvasBox.addEventListener('touchmove', ev => {
|
|
const left = ev.targetTouches[0]
|
|
const right = ev.targetTouches[1]
|
|
if (!right) {
|
|
pointer.multitouch = false
|
|
pointer.onPointerMove({
|
|
x: left.clientX,
|
|
y: left.clientY
|
|
})
|
|
} else {
|
|
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 = 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({x, y})
|
|
}
|
|
}, { passive: true })
|
|
|
|
canvasBox.addEventListener('mousedown', pointer.onPointerDown, { passive: true })
|
|
canvasBox.addEventListener('touchstart', ev => pointer.onPointerDown({
|
|
x: ev.targetTouches[0].clientX,
|
|
y: ev.targetTouches[0].clientY
|
|
}), { passive: true })
|
|
canvasBox.addEventListener('touchend', () => {
|
|
pointer.isPanning = false
|
|
pointer.multitouch = true
|
|
}, { passive: true })
|
|
canvasBox.addEventListener('mouseup', () => pointer.isPanning = false, { passive: true })
|
|
canvasBox.addEventListener('mouseleave', () => pointer.isPanning = false, { passive: true })
|
|
|
|
function update() {
|
|
const computed = getComputedStyle(canvasBox)
|
|
|
|
// Scroll
|
|
const prevScale = +computed.getPropertyValue('--scale')
|
|
// const parsedScale = +(canvasBox.dataset.scale || '')
|
|
// .1 <= scale <= 20
|
|
const scale = Math.max(.1, Math.min(20, prevScale + pointer.aggregateScroll))
|
|
|
|
// Position
|
|
const prevX = +computed.getPropertyValue('--x').slice(0, -2)
|
|
const prevY = +computed.getPropertyValue('--y').slice(0, -2)
|
|
|
|
const x = Math.max(
|
|
-image.width + PADDING_BOX,
|
|
Math.min(rect.width - PADDING_BOX, prevX + pointer.aggregateX)
|
|
)
|
|
const y = Math.max(
|
|
-image.height + PADDING_BOX,
|
|
Math.min(rect.height - PADDING_BOX, prevY + pointer.aggregateY)
|
|
)
|
|
|
|
canvasBox.style.setProperty('--x', `${x}px`)
|
|
canvasBox.style.setProperty('--y', `${y}px`)
|
|
canvasBox.style.setProperty('--scale', `${scale}`)
|
|
|
|
pointer.aggregateX = 0
|
|
pointer.aggregateY = 0
|
|
pointer.aggregateScroll = 0
|
|
|
|
requestAnimationFrame(update)
|
|
}
|
|
requestAnimationFrame(update)
|
|
}
|
|
|
|
// Load the initial view
|
|
Sakura.loadView('welcome')
|