Includes homelab deploy tooling, image position/zoom manifest persistence, and full trading/casino stack. Co-authored-by: Cursor <cursoragent@cursor.com>
199 lines
7.1 KiB
JavaScript
199 lines
7.1 KiB
JavaScript
import {
|
||
FRAMES,
|
||
computeFramedLayout,
|
||
focusFromLayout,
|
||
applyFramedLayout,
|
||
loadImage,
|
||
} from './framed-image.js';
|
||
|
||
/**
|
||
* Öffnet den Positions-Editor nach Dateiauswahl (oder für bestehendes Bild).
|
||
* @returns {Promise<{focus:{x,y}, zoom:number}|null>}
|
||
*/
|
||
export async function openImagePositionEditor({ file, url, focus, zoom, frame = 'avatar' } = {}) {
|
||
const preset = FRAMES[frame] || FRAMES.avatar;
|
||
let src = url;
|
||
if (file) src = URL.createObjectURL(file);
|
||
|
||
let img;
|
||
try {
|
||
img = await loadImage(src);
|
||
} catch (e) {
|
||
if (file) URL.revokeObjectURL(src);
|
||
throw e;
|
||
}
|
||
|
||
const state = {
|
||
focus: { x: focus?.x ?? 0.5, y: focus?.y ?? 0.5 },
|
||
zoom: Math.max(1, zoom ?? 1),
|
||
img,
|
||
dragging: false,
|
||
lastX: 0,
|
||
lastY: 0,
|
||
pinchDist: 0,
|
||
pinchZoom: 1,
|
||
};
|
||
|
||
return new Promise((resolve) => {
|
||
const backdrop = document.createElement('div');
|
||
backdrop.className = 'modal-backdrop image-editor-backdrop';
|
||
backdrop.innerHTML = `
|
||
<div class="modal image-editor-modal">
|
||
<div class="handle"></div>
|
||
<div class="modal-head">
|
||
<h2>${preset.label} positionieren</h2>
|
||
<button type="button" class="modal-close" data-act="cancel">✕</button>
|
||
</div>
|
||
<p class="muted center image-editor-hint">So wird dein Bild überall angezeigt — ziehen & zoomen</p>
|
||
<div class="image-editor-preview-wrap">
|
||
<div class="image-editor-preview ${preset.cssClass} ${preset.previewClass || 'framed-image--avatar-xl'}" id="ieFrame">
|
||
<img id="ieImg" src="${src}" alt="" draggable="false" />
|
||
</div>
|
||
</div>
|
||
<div class="field image-editor-zoom-field">
|
||
<label>Zoom <span id="ieZoomVal">${state.zoom.toFixed(1)}×</span></label>
|
||
<input type="range" id="ieZoom" min="1" max="4" step="0.05" value="${state.zoom}" />
|
||
</div>
|
||
<div class="btn-row">
|
||
<button type="button" class="btn btn-ghost" data-act="reset">Zurücksetzen</button>
|
||
<button type="button" class="btn btn-accent" 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, scale: 1 };
|
||
|
||
function syncFromFocus() {
|
||
const fw = frameEl.clientWidth;
|
||
const fh = frameEl.clientHeight;
|
||
layout = computeFramedLayout(img.naturalWidth, img.naturalHeight, fw, fh, state.focus, state.zoom);
|
||
applyFramedLayout(imgEl, layout);
|
||
}
|
||
|
||
function syncFocusFromLayout() {
|
||
const fw = frameEl.clientWidth;
|
||
const fh = frameEl.clientHeight;
|
||
state.focus = focusFromLayout(img.naturalWidth, img.naturalHeight, fw, fh, layout, state.zoom);
|
||
}
|
||
|
||
function finish(result) {
|
||
document.body.style.overflow = '';
|
||
backdrop.remove();
|
||
if (file) URL.revokeObjectURL(src);
|
||
resolve(result);
|
||
}
|
||
|
||
function onPointerDown(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();
|
||
}
|
||
|
||
function onPointerMove(e) {
|
||
if (!state.dragging) return;
|
||
const dx = e.clientX - state.lastX;
|
||
const dy = e.clientY - state.lastY;
|
||
state.lastX = e.clientX;
|
||
state.lastY = e.clientY;
|
||
const fw = frameEl.clientWidth;
|
||
const fh = frameEl.clientHeight;
|
||
layout.left = Math.min(0, Math.max(fw - layout.width, layout.left + dx));
|
||
layout.top = Math.min(0, Math.max(fh - layout.height, layout.top + dy));
|
||
applyFramedLayout(imgEl, layout);
|
||
syncFocusFromLayout();
|
||
}
|
||
|
||
function onPointerUp(e) {
|
||
state.dragging = false;
|
||
try { frameEl.releasePointerCapture(e.pointerId); } catch { /* ignore */ }
|
||
}
|
||
|
||
function onWheel(e) {
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? -0.08 : 0.08;
|
||
setZoom(state.zoom + delta);
|
||
}
|
||
|
||
function setZoom(z) {
|
||
const fw = frameEl.clientWidth;
|
||
const fh = frameEl.clientHeight;
|
||
const oldZoom = state.zoom;
|
||
state.zoom = Math.min(4, Math.max(1, z));
|
||
const oldLayout = { ...layout };
|
||
layout = computeFramedLayout(img.naturalWidth, img.naturalHeight, fw, fh, state.focus, state.zoom);
|
||
// Fokuspunkt in der Mitte halten beim Zoomen
|
||
const cx = fw / 2;
|
||
const cy = fh / 2;
|
||
const relX = (cx - oldLayout.left) / oldLayout.width;
|
||
const relY = (cy - oldLayout.top) / oldLayout.height;
|
||
layout.left = cx - relX * layout.width;
|
||
layout.top = cy - relY * 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();
|
||
applyFramedLayout(imgEl, layout);
|
||
zoomInput.value = state.zoom;
|
||
zoomVal.textContent = `${state.zoom.toFixed(1)}×`;
|
||
}
|
||
|
||
imgEl.addEventListener('load', syncFromFocus, { once: true });
|
||
if (imgEl.complete) syncFromFocus();
|
||
else requestAnimationFrame(syncFromFocus);
|
||
|
||
frameEl.addEventListener('pointerdown', onPointerDown);
|
||
frameEl.addEventListener('pointermove', onPointerMove);
|
||
frameEl.addEventListener('pointerup', onPointerUp);
|
||
frameEl.addEventListener('pointercancel', onPointerUp);
|
||
frameEl.addEventListener('wheel', onWheel, { passive: false });
|
||
|
||
// Pinch-Zoom auf Touch
|
||
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('[data-act="cancel"]').onclick = () => finish(null);
|
||
backdrop.querySelector('[data-act="reset"]').onclick = () => {
|
||
state.focus = { x: 0.5, y: 0.5 };
|
||
state.zoom = 1;
|
||
zoomInput.value = 1;
|
||
zoomVal.textContent = '1.0×';
|
||
syncFromFocus();
|
||
};
|
||
backdrop.querySelector('[data-act="save"]').onclick = () => {
|
||
syncFocusFromLayout();
|
||
finish({ focus: { ...state.focus }, zoom: state.zoom });
|
||
};
|
||
});
|
||
}
|