class MetaParser { static parse() { let file; const metas = document.querySelectorAll("meta[name]"); metas.forEach(meta => { const name = meta.getAttribute("name"); const data = this.metaToObject(meta); if (name === "file") { file = data; } }); return file; } static metaToObject(meta) { const obj = {}; for (const attr of meta.attributes) { let value = attr.value; if (value === "true") { value = true; } else if (value === "false") { value = false; } else if (Number.isSafeInteger(value) && !isNaN(value) && value.trim() !== "") { value = Number(value); } obj[attr.name] = value; } return obj; } } class Viewer { constructor(wrapper, content, header, image) { this.wrapper = wrapper; this.content = content; this.header = header; this.rawImage = image; this.wrapper.addEventListener('wheel', this.wheel, { passive: false }) this.wrapper.addEventListener('pointerdown', this.down) this.wrapper.addEventListener('pointermove', this.move) this.wrapper.addEventListener('pointerup', this.up) this.wrapper.addEventListener('pointercancel', this.up) this.state = { x: 0, y: 0, scale: 1 } this.tap = { startX: 0, startY: 0, moved: false, isMultiTouch: false } this.input = { pointers: new Map(), wheel: null, dirty: false } this.scale = { min: 0.5, max: 30 } this.prevPan = null this.prevPinch = null document.body.style.touchAction = "none"; this.loop(); } down = (e) => { this.wrapper.style.cursor = "grabbing" this.content.style.willChange = "transform" this.wrapper.setPointerCapture(e.pointerId) this.input.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, }) if (this.input.pointers.size === 1) { this.tap.startX = e.clientX this.tap.startY = e.clientY this.tap.moved = false this.tap.isMultiTouch = false } else { this.tap.isMultiTouch = true } this.input.dirty = true } move = (e) => { const p = this.input.pointers.get(e.pointerId) if (!p) return p.x = e.clientX p.y = e.clientY this.tap.moved = Math.hypot( e.clientX - this.tap.startX, e.clientY - this.tap.startY ) > 5 this.input.dirty = true } up = (e) => { this.wrapper.style.cursor = "" this.content.style.willChange = "" this.input.pointers.delete(e.pointerId) if (this.input.pointers.size === 0 && !this.tap.moved && !this.tap.isMultiTouch) { this.rawImage.style.opacity = this.rawImage.style.opacity === "0" ? "1" : "0"; this.header.style.display = this.rawImage.style.opacity === "0" ? "none" : "flex"; } this.input.dirty = true } wheel = (e) => { e.preventDefault() this.input.wheel = { x: e.clientX, y: e.clientY, delta: e.deltaY, } this.input.dirty = true } loop = () => { this.update() requestAnimationFrame(this.loop); } update() { if (!this.input.dirty) return this.input.dirty = false const pts = [...this.input.pointers.values()] if (this.input.wheel) { const { x, y, delta } = this.input.wheel const scaleFactor = Math.exp(-delta * 0.001) this.zoomAt(x, y, scaleFactor) this.input.wheel = null } if (pts.length === 1) { this.pan(pts[0]) } else if (pts.length >= 2) { this.pinch(pts[0], pts[1]) } if (pts.length < 2) this.prevPinch = null if (pts.length !== 1) this.prevPan = null this.render() } pan(p) { if (!this.prevPan) { this.prevPan = { ...p } return } this.state.x += p.x - this.prevPan.x this.state.y += p.y - this.prevPan.y this.prevPan = { ...p } } pinch(p1, p2) { const center = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2, } const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y) if (!this.prevPinch) { this.prevPinch = { center, dist } return } const scaleFactor = dist / this.prevPinch.dist this.zoomAt(center.x, center.y, scaleFactor) this.state.x += center.x - this.prevPinch.center.x this.state.y += center.y - this.prevPinch.center.y this.prevPinch = { center, dist } } zoomAt(cx, cy, scaleFactor) { const rect = this.content.getBoundingClientRect() const dx = cx - (rect.left + rect.width / 2) const dy = cy - (rect.top + rect.height / 2) const prevScale = this.state.scale const newScale = Math.max(this.scale.min, Math.min(this.scale.max, prevScale * scaleFactor)) const ratio = newScale / prevScale this.state.x -= dx * (ratio - 1) this.state.y -= dy * (ratio - 1) this.state.scale = newScale } render() { const { x, y, scale } = this.state this.content.style.transform = `translate(${x}px, ${y}px) scale(${scale})` } reset() { this.state.x = 0 this.state.y = 0 this.state.scale = 1 this.render(); } } document.addEventListener('DOMContentLoaded', () => { let file = MetaParser.parse(), requesting = false; let preview = document.getElementById("preview"); preview.style.display = "block"; let previewHeader = document.getElementById("preview-header"); let qualitySelector = document.getElementById("quality-selector"); qualitySelector.addEventListener("change", switchQuality.bind(this)); let convertButton = document.getElementById("convert-button"); convertButton.addEventListener("click", sendConvertRequest.bind(this)); let saveButton = document.getElementById("save-button"); saveButton.addEventListener("click", convertDraftDone.bind(this)); let resetButton = document.getElementById("reset-button"); resetButton.addEventListener("click", resetImage.bind(this)); let previewView = document.getElementById("preview-view"); let imageWrapper = document.getElementById("image-wrapper"); let previewImg = document.getElementById("preview-img"); let originalImg = document.getElementById("original-img"); new Viewer(previewView, imageWrapper, previewHeader, previewImg); document.addEventListener('keydown', (e) => { if (e.ctrlKey) { previewImg.style.opacity = 0 previewHeader.style.display = "none" } switch (e.key) { case 'ArrowDown': e.preventDefault(); if (qualitySelector.value <= 35) return; qualitySelector.value = String(Number(qualitySelector.value) - 5) switchQuality(); break; case 'ArrowUp': e.preventDefault(); if (qualitySelector.value >= 90) return; qualitySelector.value = String(Number(qualitySelector.value) + 5) switchQuality(); break; } }); document.addEventListener('keyup', (e) => { if (e.key === 'Control') { previewImg.style.opacity = 1 previewHeader.style.display = "flex" } }); originalImg.addEventListener("error", collectRaw.bind(this)); let originalSize = document.getElementById("original-size"); if (originalSize.textContent == "") { collectRaw(); } else { originalImg.src = `/files/${file.id_str}.${file.raw_ext}`; } function switchQuality() { quality = qualitySelector.value; let element = document.getElementById("q" + quality); if (element.dataset.converted) { previewImg.src = `/files/${file.id_str}_q${quality}.webp`; previewImg.style.opacity = 1 previewHeader.style.backgroundColor = "#333" } } function sendConvertRequest() { if (requesting) { alert("Image is converting"); return; } quality = qualitySelector.value; let element = document.getElementById("q" + quality); if (element.dataset.converted) { alert(`Image already converted with ${quality}% quality`); return; } else if (element.dataset.oversize) { alert(`Image is too large to convert with ${quality}% quality`); return; } requesting = true; convertButton.textContent = "converting" convertButton.style.backgroundColor = "rgb(50, 80, 80)"; fetch(`/api/task/files/convert-draft/webp/${file.id_str}/${quality}`) .then(response => { response.json().then((resjson) => { switch (response.status) { case 200: element.dataset.converted = true; element.textContent += ` - ${resjson.size_str_converted}`; previewImg.src = `/files/${file.id_str}_q${quality}.webp`; break; case 417: element.dataset.oversize = true; element.textContent += ` - oversize`; alert(`Image converted size bigger than original size, cancelled`) break; default: alert(`转换发生错误:${resjson.msg}: ${resjson.error}`); } }); }) .finally(() => { convertButton.textContent = "convert" convertButton.style.backgroundColor = "#444"; requesting = false; }) .catch(error => { alert(`请求失败: ${error}`); }); } function convertDraftDone() { if (requesting) { alert("Request is already in progress"); return; } quality = qualitySelector.value; requesting = true; saveButton.textContent = "saving" saveButton.style.backgroundColor = "rgb(50, 80, 80)"; fetch(`/api/task/files/convert-draft-done/webp/${file.id_str}/${quality}`) .then(response => { response.json().then((resjson) => { switch (response.status) { case 200: break; default: alert(`发生错误:${resjson.msg}: ${resjson.error}`); } }); }) .finally(() => { saveButton.textContent = "saved" saveButton.style.backgroundColor = "#444"; requesting = false; }) .catch(error => { alert(`请求失败: ${error}`); }); } function resetImage() { if (requesting) { alert("another request is already in progress"); return; } requesting = true; resetButton.textContent = "resetting" resetButton.style.backgroundColor = "rgb(50, 80, 80)"; fetch(`/api/task/files/convert-reset/${file.id_str}`) .then(response => { response.json().then((resjson) => { switch (response.status) { case 200: break; default: alert(`发生错误:${resjson.msg}: ${resjson.error}`); } }); }) .finally(() => { resetButton.textContent = "reset" resetButton.style.backgroundColor = "#444"; requesting = false; }) .catch(error => { alert(`请求失败: ${error}`); }); } function collectRaw() { if (confirm(`The original image could not be loaded. Need try download it to the server?`)) { fetch(`/api/task/files/convert-raw/${file.id_str}`) .then(response => { response.json().then((resjson) => { switch (response.status) { case 200: originalImg.src = resjson.url; originalSize.textContent = resjson.size_str; break; default: alert(`发生错误:${resjson.msg}: ${resjson.error}`); return } }); }) .catch(error => { alert(`请求失败: ${error}`); }); } } });