mirror of
https://github.com/tronbyt/server.git
synced 2026-08-31 06:57:10 +02:00
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
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:
@@ -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 0–100 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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user