Add crop, pan, and zoom controls for image uploads on Shuffle Images app (#899)
Build and test / Lint & Quality Checks (push) Waiting to run
Build and test / build-and-test (tronbyt-server-darwin-arm64, arm64, darwin, macos-26) (push) Waiting to run
Build and test / build-and-test (tronbyt-server-linux-amd64, amd64, linux, ubuntu-24.04) (push) Waiting to run
Build and test / build-and-test (tronbyt-server-linux-arm64, arm64, linux, ubuntu-24.04-arm) (push) Waiting to run
Build and test / build-and-test (tronbyt-server-windows-amd64.exe, amd64, windows, windows-2025) (push) Waiting to run
Build and test / Create Release (push) Blocked by required conditions
Create and publish a container image / build-and-push-image (push) Waiting to run

* Update configapp.html

* Add image cropping and zoom controls

* Address image crop review feedback

* Ignore stale image upload results

* use integers for scaling to fix firefox

* show the current images on app edit and don't save animated webp/gifs

* detect actual animations not by gif/webs file extensions

---------

Co-authored-by: Tavis <github@tavis.la>
This commit is contained in:
wafflestomper08
2026-08-30 11:45:02 -07:00
committed by GitHub
parent 2a7aeb8959
commit 44dc34912c
+416 -49
View File
@@ -1188,65 +1188,432 @@
return container;
}
function createImageUploadField(field, config) {
const container = document.createElement("div");
function createImageUploadField(field, config) {
const container = document.createElement("div");
const uploadLabel = document.createElement("label");
uploadLabel.htmlFor = `${field.id}_upload`;
uploadLabel.textContent = "{{ t .Localizer "Upload Image" }}";
container.appendChild(uploadLabel);
const uploadLabel = document.createElement("label");
uploadLabel.htmlFor = `${field.id}_upload`;
uploadLabel.textContent = "{{ t .Localizer "Upload Image" }}";
container.appendChild(uploadLabel);
const uploadInput = document.createElement("input");
uploadInput.type = "file";
uploadInput.id = `${field.id}_upload`;
uploadInput.setAttribute("data-ignore-config", "true");
uploadInput.accept = "image/png, image/jpeg, image/gif, image/svg+xml, image/webp";
container.appendChild(uploadInput);
const uploadInput = document.createElement("input");
uploadInput.type = "file";
uploadInput.id = `${field.id}_upload`;
uploadInput.setAttribute("data-ignore-config", "true");
uploadInput.accept = "image/png, image/jpeg, image/gif, image/webp";
container.appendChild(uploadInput);
const hiddenInput = document.createElement("input");
hiddenInput.type = "hidden";
hiddenInput.id = "schema_" + field.id;
hiddenInput.dataset.configId = field.id;
hiddenInput.name = field.id;
hiddenInput.value = config[field.id] || "";
container.appendChild(hiddenInput);
const cropArea = document.createElement("div");
cropArea.style.width = "100%";
cropArea.style.maxWidth = "640px";
cropArea.style.aspectRatio = "2 / 1";
cropArea.style.position = "relative";
cropArea.style.overflow = "hidden";
cropArea.style.background = "#000";
cropArea.style.marginTop = "10px";
cropArea.style.cursor = "grab";
cropArea.style.display = "none";
cropArea.setAttribute("data-ignore-config", "true");
container.appendChild(cropArea);
const previewImage = document.createElement("img");
previewImage.id = `${field.id}_preview`;
previewImage.src = config[field.id] ? `data:image/png;base64,${config[field.id]}` : "";
previewImage.alt = "{{ t .Localizer "Preview" }}";
previewImage.style.maxWidth = "100%";
previewImage.style.height = "auto";
previewImage.style.marginTop = "10px";
previewImage.style.display = config[field.id] ? "inline" : "none";
container.appendChild(previewImage);
const cropImage = document.createElement("img");
cropImage.style.position = "absolute";
cropImage.style.left = "0";
cropImage.style.top = "0";
cropImage.style.transformOrigin = "0 0";
cropImage.style.maxWidth = "none";
cropImage.style.userSelect = "none";
cropImage.style.pointerEvents = "none";
cropArea.appendChild(cropImage);
uploadInput.addEventListener("change", event => {
const file = event.target.files[0];
if (!file) return;
const cropFrame = document.createElement("div");
cropFrame.style.position = "absolute";
cropFrame.style.inset = "0";
cropFrame.style.border = "2px solid white";
cropFrame.style.boxSizing = "border-box";
cropFrame.style.pointerEvents = "none";
cropArea.appendChild(cropFrame);
const maxSize = 500 * 1024;
if (file.size > maxSize) {
alert("{{ t .Localizer "File size exceeds 500KB limit. Please upload a smaller image." }}");
uploadInput.value = "";
return;
}
const zoomLabel = document.createElement("label");
zoomLabel.htmlFor = `${field.id}_zoom`;
zoomLabel.textContent = "{{ t .Localizer "Zoom" }}";
zoomLabel.style.display = "none";
zoomLabel.style.marginTop = "8px";
container.appendChild(zoomLabel);
const reader = new FileReader();
reader.onload = e => {
const base64Data = e.target.result.split(",")[1];
hiddenInput.value = base64Data;
previewImage.src = e.target.result;
previewImage.style.display = "inline";
config[field.id] = base64Data;
updateConfigAndPreview();
};
reader.readAsDataURL(file);
});
// Integer 0100 mapped onto [minScale, maxScale]. Gecko constraint-validates
// range inputs, so a float min with step="0.01" blocks submit with
// "Please select a valid value" and two long decimal neighbors.
const zoomInput = document.createElement("input");
zoomInput.type = "range";
zoomInput.id = `${field.id}_zoom`;
zoomInput.min = "0";
zoomInput.max = "100";
zoomInput.step = "1";
zoomInput.value = "0";
zoomInput.style.width = "100%";
zoomInput.style.display = "none";
zoomInput.setAttribute("data-ignore-config", "true");
container.appendChild(zoomInput);
return container;
const hiddenInput = document.createElement("input");
hiddenInput.type = "hidden";
hiddenInput.id = "schema_" + field.id;
hiddenInput.dataset.configId = field.id;
hiddenInput.name = field.id;
hiddenInput.value = config[field.id] || "";
container.appendChild(hiddenInput);
let minScale = 1;
let maxScale = 5;
let imageScale = 1;
let imageX = 0;
let imageY = 0;
let preserveAnimation = false;
let dragging = false;
let dragStartX = 0;
let dragStartY = 0;
let imageStartX = 0;
let imageStartY = 0;
function clampImagePosition() {
if (!cropImage.naturalWidth || !cropImage.naturalHeight) return;
const areaWidth = cropArea.clientWidth;
const areaHeight = cropArea.clientHeight;
const imageWidth = cropImage.naturalWidth * imageScale;
const imageHeight = cropImage.naturalHeight * imageScale;
if (imageWidth <= areaWidth) {
imageX = (areaWidth - imageWidth) / 2;
} else {
imageX = Math.min(0, Math.max(areaWidth - imageWidth, imageX));
}
if (imageHeight <= areaHeight) {
imageY = (areaHeight - imageHeight) / 2;
} else {
imageY = Math.min(0, Math.max(areaHeight - imageHeight, imageY));
}
}
function scaleFromZoomSlider() {
const t = Number(zoomInput.value) / 100;
return minScale + t * (maxScale - minScale);
}
function updateImagePosition() {
clampImagePosition();
cropImage.style.transform =
`translate(${imageX}px, ${imageY}px) scale(${imageScale})`;
}
function storedPayload(value) {
const raw = String(value || "");
return raw.startsWith("data:") ? raw.slice(raw.indexOf(",") + 1) : raw;
}
// Canvas drawImage + toDataURL flattens animated GIF/WebP to a single PNG frame.
function decodeBase64Bytes(payload) {
try {
const binary = atob(String(payload || "").replace(/\s/g, ""));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
} catch (e) {
return null;
}
}
function readU32LE(bytes, offset) {
if (offset + 4 > bytes.length) return null;
return (
bytes[offset] |
(bytes[offset + 1] << 8) |
(bytes[offset + 2] << 16) |
(bytes[offset + 3] << 24)
) >>> 0;
}
function asciiAt(bytes, offset, length) {
if (offset + length > bytes.length) return "";
return String.fromCharCode(...bytes.subarray(offset, offset + length));
}
function skipGifSubBlocks(bytes, offset) {
let i = offset;
while (i < bytes.length) {
const len = bytes[i];
i += 1;
if (len === 0) return i;
i += len;
}
return -1;
}
function gifHasMultipleFrames(bytes) {
if (bytes.length < 13) return false;
const header = asciiAt(bytes, 0, 6);
if (header !== "GIF87a" && header !== "GIF89a") return false;
let i = 13;
const packed = bytes[10];
if (packed & 0x80) {
i += 3 * (1 << ((packed & 0x07) + 1));
}
let frames = 0;
while (i < bytes.length) {
const marker = bytes[i];
if (marker === 0x3B) break;
if (marker === 0x2C) {
if (i + 10 > bytes.length) return false;
const imgPacked = bytes[i + 9];
frames += 1;
if (frames >= 2) return true;
i += 10;
if (imgPacked & 0x80) {
i += 3 * (1 << ((imgPacked & 0x07) + 1));
}
if (i >= bytes.length) return false;
i += 1;
i = skipGifSubBlocks(bytes, i);
if (i < 0) return false;
continue;
}
if (marker === 0x21) {
if (i + 2 > bytes.length) return false;
i = skipGifSubBlocks(bytes, i + 2);
if (i < 0) return false;
continue;
}
return false;
}
return false;
}
function webpHasAnimChunk(bytes) {
if (asciiAt(bytes, 0, 4) !== "RIFF" || asciiAt(bytes, 8, 4) !== "WEBP") {
return false;
}
const riffSize = readU32LE(bytes, 4);
if (riffSize === null) return false;
const end = Math.min(bytes.length, 8 + riffSize);
let i = 12;
while (i + 8 <= end) {
const fourcc = asciiAt(bytes, i, 4);
const size = readU32LE(bytes, i + 4);
if (size === null) return false;
if (fourcc === "ANIM") return true;
const payloadEnd = i + 8 + size + (size % 2);
if (payloadEnd <= i) return false;
i = payloadEnd;
}
return false;
}
function shouldPreserveAnimation(value) {
const bytes = decodeBase64Bytes(storedPayload(value));
if (!bytes) return false;
if (gifHasMultipleFrames(bytes)) return true;
if (webpHasAnimChunk(bytes)) return true;
return false;
}
function showCropEditor() {
cropArea.style.display = "block";
zoomLabel.style.display = preserveAnimation ? "none" : "block";
zoomInput.style.display = preserveAnimation ? "none" : "block";
cropArea.style.cursor = preserveAnimation ? "default" : "grab";
}
function fitImageToCropArea() {
const areaWidth = cropArea.clientWidth;
const areaHeight = cropArea.clientHeight;
if (!areaWidth || !areaHeight || !cropImage.naturalWidth) {
return false;
}
const initialScale = Math.max(
areaWidth / cropImage.naturalWidth,
areaHeight / cropImage.naturalHeight
);
minScale = initialScale;
maxScale = Math.max(initialScale * 5, 5);
imageScale = minScale;
zoomInput.value = "0";
imageX = (areaWidth - cropImage.naturalWidth * imageScale) / 2;
imageY = (areaHeight - cropImage.naturalHeight * imageScale) / 2;
updateImagePosition();
return true;
}
function whenCropAreaSized(callback) {
if (cropArea.clientWidth && cropArea.clientHeight) {
callback();
return;
}
const observer = new ResizeObserver(() => {
if (!cropArea.clientWidth || !cropArea.clientHeight) return;
observer.disconnect();
callback();
});
observer.observe(cropArea);
}
function loadImageIntoCropper(src, recrop) {
preserveAnimation = shouldPreserveAnimation(src);
if (recrop && preserveAnimation) {
const base64Data = storedPayload(src);
hiddenInput.value = base64Data;
config[field.id] = base64Data;
updateConfigAndPreview();
}
cropImage.onload = () => {
showCropEditor();
whenCropAreaSized(() => {
fitImageToCropArea();
if (recrop && !preserveAnimation) {
saveCrop();
}
});
};
cropImage.src = src;
}
function saveCrop() {
if (preserveAnimation || !cropImage.src) return;
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 32;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "black";
ctx.fillRect(0, 0, 64, 32);
const areaWidth = cropArea.clientWidth;
const areaHeight = cropArea.clientHeight;
ctx.save();
ctx.scale(64 / areaWidth, 32 / areaHeight);
ctx.translate(imageX, imageY);
ctx.scale(imageScale, imageScale);
ctx.drawImage(cropImage, 0, 0);
ctx.restore();
const resizedDataUrl = canvas.toDataURL("image/png");
const base64Data = resizedDataUrl.split(",")[1];
hiddenInput.value = base64Data;
config[field.id] = base64Data;
updateConfigAndPreview();
}
let selectionGeneration = 0;
uploadInput.addEventListener("change", event => {
const file = event.target.files[0];
if (!file) return;
const generation = ++selectionGeneration;
const reader = new FileReader();
reader.onload = e => {
if (generation !== selectionGeneration) return;
loadImageIntoCropper(e.target.result, true);
};
reader.readAsDataURL(file);
});
zoomInput.addEventListener("input", () => {
if (preserveAnimation) return;
const oldScale = imageScale;
const newScale = scaleFromZoomSlider();
const centerX = cropArea.clientWidth / 2;
const centerY = cropArea.clientHeight / 2;
imageX =
centerX - (centerX - imageX) * (newScale / oldScale);
imageY =
centerY - (centerY - imageY) * (newScale / oldScale);
imageScale = newScale;
updateImagePosition();
saveCrop();
});
function stopDragging(event, save = true) {
if (!dragging) return;
dragging = false;
cropArea.style.cursor = "grab";
if (event && cropArea.hasPointerCapture(event.pointerId)) {
cropArea.releasePointerCapture(event.pointerId);
}
if (save) {
saveCrop();
}
}
cropArea.addEventListener("pointerdown", event => {
if (preserveAnimation) return;
dragging = true;
dragStartX = event.clientX;
dragStartY = event.clientY;
imageStartX = imageX;
imageStartY = imageY;
cropArea.style.cursor = "grabbing";
cropArea.setPointerCapture(event.pointerId);
});
cropArea.addEventListener("pointermove", event => {
if (!dragging) return;
imageX = imageStartX + (event.clientX - dragStartX);
imageY = imageStartY + (event.clientY - dragStartY);
updateImagePosition();
});
cropArea.addEventListener("pointerup", event => {
stopDragging(event);
});
cropArea.addEventListener("pointercancel", event => {
stopDragging(event);
});
const existing = config[field.id];
if (existing) {
const raw = String(existing);
let dataUrl = raw;
if (!raw.startsWith("data:")) {
let mime = "image/png";
if (raw.startsWith("/9j/")) mime = "image/jpeg";
else if (raw.startsWith("R0lGOD")) mime = "image/gif";
else if (raw.startsWith("UklGR")) mime = "image/webp";
dataUrl = `data:${mime};base64,${raw}`;
}
// Wait until the field has been inserted into the form so the crop
// area has a layout size before we fit the restored image.
queueMicrotask(() => loadImageIntoCropper(dataUrl, false));
}
return container;
}
function createTypeaheadField(field, config) {
let inputElement = document.createElement("div");