Files
playbull/public/image-editor.js
Mike 94b6cc3be9 Initial PlayBull release with Trilogy Hub integration.
Includes homelab deploy tooling, image position/zoom manifest persistence, and full trading/casino stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 08:31:36 +02:00

191 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Trilogy Hub — Bild-Positions-Editor (gleicher Rahmen wie Slot-Anzeige) */
(() => {
"use strict";
function computeLayout(imgW, imgH, frameW, frameH, focus, zoom) {
const fx = focus?.x ?? 0.5;
const fy = focus?.y ?? 0.5;
const z = Math.max(1, zoom ?? 1);
const cover = Math.max(frameW / imgW, frameH / imgH);
const scale = cover * z;
const w = imgW * scale;
const h = imgH * scale;
let left = frameW / 2 - fx * w;
let top = frameH / 2 - fy * h;
left = Math.min(0, Math.max(frameW - w, left));
top = Math.min(0, Math.max(frameH - h, top));
return { width: w, height: h, left, top };
}
function focusFromLayout(imgW, imgH, frameW, frameH, layout, zoom) {
const z = Math.max(1, zoom ?? 1);
const cover = Math.max(frameW / imgW, frameH / imgH);
const w = imgW * cover * z;
const h = imgH * cover * z;
return {
x: Math.min(1, Math.max(0, (frameW / 2 - layout.left) / w)),
y: Math.min(1, Math.max(0, (frameH / 2 - layout.top) / h)),
};
}
function applyLayout(img, layout) {
img.style.width = layout.width + "px";
img.style.height = layout.height + "px";
img.style.left = layout.left + "px";
img.style.top = layout.top + "px";
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Bild konnte nicht geladen werden"));
img.src = src;
});
}
function slotFrameSize(slot) {
const cs = getComputedStyle(slot);
let w = slot.offsetWidth;
let h = slot.offsetHeight;
if (w < 40) w = slot.parentElement?.offsetWidth || 320;
if (h < 40) {
const mh = cs.minHeight;
h = mh && mh !== "0px" ? parseFloat(mh) : 220;
}
const isHero = slot.classList.contains("!rounded-none") || slot.closest(".min-h-\\[72vh\\]");
const maxPreview = isHero ? 400 : 300;
const ar = w / h;
if (w > maxPreview) { w = maxPreview; h = w / ar; }
return { width: Math.round(w), height: Math.round(h), radius: cs.borderRadius, label: slot.dataset.slot || "Bild" };
}
async function open({ file, slot, focus, zoom, imageSrc }) {
let src = imageSrc;
let revoke = null;
if (file) { src = URL.createObjectURL(file); revoke = src; }
const frame = slotFrameSize(slot);
let img;
try { img = await loadImage(src); }
catch (e) { if (revoke) URL.revokeObjectURL(revoke); throw e; }
const state = {
focus: { x: focus?.x ?? 0.5, y: focus?.y ?? 0.5 },
zoom: Math.max(1, zoom ?? 1),
dragging: false, lastX: 0, lastY: 0, pinchDist: 0, pinchZoom: 1,
};
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "img-editor-backdrop";
backdrop.innerHTML = `
<div class="img-editor-modal card card-gold">
<div class="img-editor-head">
<h2 class="font-display font-bold text-xl">${frame.label} positionieren</h2>
<button type="button" class="btn btn-ghost !min-h-0 !p-2.5 img-editor-close" aria-label="Schließen">✕</button>
</div>
<p class="text-dim text-sm text-center mb-5">So wird das Bild im Rahmen angezeigt — ziehen &amp; zoomen</p>
<div class="img-editor-preview-outer">
<div class="img-editor-preview" id="ieFrame" style="width:${frame.width}px;height:${frame.height}px;border-radius:${frame.radius}">
<img id="ieImg" src="${src}" alt="" draggable="false" />
</div>
</div>
<label class="block text-sm font-semibold mb-2 mt-5">Zoom <span id="ieZoomVal">${state.zoom.toFixed(1)}×</span></label>
<input type="range" id="ieZoom" class="img-editor-range" min="1" max="4" step="0.05" value="${state.zoom}" />
<div class="flex gap-3 mt-6">
<button type="button" class="btn btn-ghost flex-1" data-act="reset">Zurücksetzen</button>
<button type="button" class="btn btn-primary flex-1" data-act="save">Speichern</button>
</div>
</div>`;
document.body.appendChild(backdrop);
document.body.style.overflow = "hidden";
const frameEl = backdrop.querySelector("#ieFrame");
const imgEl = backdrop.querySelector("#ieImg");
const zoomInput = backdrop.querySelector("#ieZoom");
const zoomVal = backdrop.querySelector("#ieZoomVal");
let layout = { left: 0, top: 0, width: 0, height: 0 };
const fw = () => frameEl.clientWidth;
const fh = () => frameEl.clientHeight;
function syncFromFocus() {
layout = computeLayout(img.naturalWidth, img.naturalHeight, fw(), fh(), state.focus, state.zoom);
applyLayout(imgEl, layout);
}
function syncFocusFromLayout() {
state.focus = focusFromLayout(img.naturalWidth, img.naturalHeight, fw(), fh(), layout, state.zoom);
}
function finish(result) {
document.body.style.overflow = "";
backdrop.remove();
if (revoke) URL.revokeObjectURL(revoke);
resolve(result);
}
function setZoom(z) {
const old = { ...layout };
state.zoom = Math.min(4, Math.max(1, z));
layout = computeLayout(img.naturalWidth, img.naturalHeight, fw(), fh(), state.focus, state.zoom);
const cx = fw() / 2, cy = fh() / 2;
const rx = (cx - old.left) / old.width;
const ry = (cy - old.top) / old.height;
layout.left = cx - rx * layout.width;
layout.top = cy - ry * layout.height;
layout.left = Math.min(0, Math.max(fw() - layout.width, layout.left));
layout.top = Math.min(0, Math.max(fh() - layout.height, layout.top));
syncFocusFromLayout();
applyLayout(imgEl, layout);
zoomInput.value = state.zoom;
zoomVal.textContent = state.zoom.toFixed(1) + "×";
}
imgEl.addEventListener("load", syncFromFocus, { once: true });
if (imgEl.complete) syncFromFocus();
frameEl.addEventListener("pointerdown", (e) => {
if (e.target.closest("button, input")) return;
state.dragging = true;
state.lastX = e.clientX;
state.lastY = e.clientY;
frameEl.setPointerCapture(e.pointerId);
e.preventDefault();
});
frameEl.addEventListener("pointermove", (e) => {
if (!state.dragging) return;
layout.left = Math.min(0, Math.max(fw() - layout.width, layout.left + e.clientX - state.lastX));
layout.top = Math.min(0, Math.max(fh() - layout.height, layout.top + e.clientY - state.lastY));
state.lastX = e.clientX;
state.lastY = e.clientY;
applyLayout(imgEl, layout);
syncFocusFromLayout();
});
frameEl.addEventListener("pointerup", () => { state.dragging = false; });
frameEl.addEventListener("pointercancel", () => { state.dragging = false; });
frameEl.addEventListener("wheel", (e) => { e.preventDefault(); setZoom(state.zoom + (e.deltaY > 0 ? -0.08 : 0.08)); }, { passive: false });
frameEl.addEventListener("touchstart", (e) => {
if (e.touches.length === 2) {
state.pinchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
state.pinchZoom = state.zoom;
}
}, { passive: true });
frameEl.addEventListener("touchmove", (e) => {
if (e.touches.length === 2 && state.pinchDist > 0) {
e.preventDefault();
const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
setZoom(state.pinchZoom * (dist / state.pinchDist));
}
}, { passive: false });
zoomInput.addEventListener("input", () => setZoom(+zoomInput.value));
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(null); });
backdrop.querySelector(".img-editor-close").onclick = () => finish(null);
backdrop.querySelector('[data-act="reset"]').onclick = () => { state.focus = { x: 0.5, y: 0.5 }; state.zoom = 1; setZoom(1); };
backdrop.querySelector('[data-act="save"]').onclick = () => { syncFocusFromLayout(); finish({ focus: { ...state.focus }, zoom: state.zoom }); };
});
}
window.TrilogyImageEditor = { open, computeLayout, applyLayout };
})();