- gallery thumbnails now object-fit: contain (whole image, letterboxed),
and a card links to the new detail page instead of the raw file
- /art/{id} detail page: large uncropped image, "Open full size",
"Delete" (moved here from the gallery), and "Copy image"
- clipboard Stimulus controller copies the image to the OS clipboard
(converts non-PNG via canvas) for pasting into WhatsApp etc.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
71 lines
2.4 KiB
JavaScript
71 lines
2.4 KiB
JavaScript
import { Controller } from '@hotwired/stimulus';
|
|
|
|
/*
|
|
* Copies an image to the OS clipboard so it can be pasted into WhatsApp etc.
|
|
*
|
|
* <button data-controller="clipboard"
|
|
* data-clipboard-url-value="/art/5/file"
|
|
* data-action="clipboard#copy">Copy image</button>
|
|
*
|
|
* The Clipboard API only reliably accepts image/png, so non-PNG sources are
|
|
* redrawn through a canvas first. Needs a secure context (https or localhost).
|
|
*/
|
|
export default class extends Controller {
|
|
static values = { url: String };
|
|
|
|
async copy(event) {
|
|
const btn = event.currentTarget;
|
|
|
|
if (!navigator.clipboard || !window.ClipboardItem) {
|
|
this.flash(btn, 'Not supported');
|
|
return;
|
|
}
|
|
|
|
this.flash(btn, 'Copying…', 0);
|
|
try {
|
|
const res = await fetch(this.urlValue, { credentials: 'same-origin' });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
let blob = await res.blob();
|
|
if (blob.type !== 'image/png') {
|
|
blob = await this.toPng(blob);
|
|
}
|
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
this.flash(btn, 'Copied!');
|
|
} catch (e) {
|
|
console.error('clipboard copy failed', e);
|
|
this.flash(btn, 'Copy failed');
|
|
}
|
|
}
|
|
|
|
toPng(blob) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
const url = URL.createObjectURL(blob);
|
|
img.onload = () => {
|
|
URL.revokeObjectURL(url);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = img.naturalWidth;
|
|
canvas.height = img.naturalHeight;
|
|
canvas.getContext('2d').drawImage(img, 0, 0);
|
|
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
|
|
};
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
reject(new Error('image decode failed'));
|
|
};
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
flash(btn, msg, revertAfter = 1800) {
|
|
if (!btn.dataset.label) btn.dataset.label = btn.textContent;
|
|
btn.textContent = msg;
|
|
clearTimeout(this._t);
|
|
if (revertAfter > 0) {
|
|
this._t = setTimeout(() => {
|
|
btn.textContent = btn.dataset.label;
|
|
}, revertAfter);
|
|
}
|
|
}
|
|
}
|