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>
This commit is contained in:
190
public/image-editor.js
Normal file
190
public/image-editor.js
Normal file
@@ -0,0 +1,190 @@
|
||||
/* 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 & 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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user