deckhearth/components/designer/ZoneEditor.js

162 lines
4.5 KiB
JavaScript
Raw Permalink Normal View History

/* eslint-disable @next/next/no-img-element -- frame image comes from the MinIO CDN; next/image is out of scope for the designer canvas. */
import { useRef } from 'react';
import { CARD_W, CARD_H, DEFAULT_LAYOUT } from './CardFrame';
const ZONE_COLORS = {
art: { fill: 'rgba(110, 168, 220, 0.18)', border: '#6ea8dc' },
text: { fill: 'rgba(143, 188, 111, 0.18)', border: '#8fbc6f' },
};
const HANDLE = 14;
/**
* Interactive zone editor for image-based frames. Renders the uploaded
* frame artwork at natural card size with draggable/resizable overlays
* for the art and text windows. Zones are fractions of the card.
*
* Drag inside a zone to move it; drag the bottom-right handle to resize.
*/
export default function ZoneEditor({ frameImageUrl, layout, onChange }) {
const dragState = useRef(null);
const clampZone = (zone) => {
const w = Math.min(Math.max(zone.w, 0.05), 1);
const h = Math.min(Math.max(zone.h, 0.05), 1);
return {
w,
h,
x: Math.min(Math.max(zone.x, 0), 1 - w),
y: Math.min(Math.max(zone.y, 0), 1 - h),
};
};
const startDrag = (zoneKey, mode) => (event) => {
event.preventDefault();
event.target.setPointerCapture?.(event.pointerId);
dragState.current = {
zoneKey,
mode,
startX: event.clientX,
startY: event.clientY,
origin: layout[zoneKey],
};
};
const onPointerMove = (event) => {
const state = dragState.current;
if (!state) return;
const dx = (event.clientX - state.startX) / CARD_W;
const dy = (event.clientY - state.startY) / CARD_H;
const o = state.origin;
let next;
if (state.mode === 'resize') {
next = clampZone({ x: o.x, y: o.y, w: o.w + dx, h: o.h + dy });
} else {
next = clampZone({ x: o.x + dx, y: o.y + dy, w: o.w, h: o.h });
}
onChange({ ...layout, [state.zoneKey]: next });
};
const endDrag = () => {
dragState.current = null;
};
const renderZone = (zoneKey) => {
const zone = layout[zoneKey] || DEFAULT_LAYOUT[zoneKey];
const colors = ZONE_COLORS[zoneKey];
return (
<div
key={zoneKey}
onPointerDown={startDrag(zoneKey, 'move')}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
style={{
position: 'absolute',
left: `${(zone.x * 100).toFixed(3)}%`,
top: `${(zone.y * 100).toFixed(3)}%`,
width: `${(zone.w * 100).toFixed(3)}%`,
height: `${(zone.h * 100).toFixed(3)}%`,
backgroundColor: colors.fill,
border: `2px dashed ${colors.border}`,
cursor: 'move',
touchAction: 'none',
userSelect: 'none',
}}
>
<span
style={{
position: 'absolute',
top: 4,
left: 6,
fontSize: 11,
fontWeight: 700,
color: colors.border,
textShadow: '0 1px 2px rgba(0,0,0,0.8)',
pointerEvents: 'none',
}}
>
{zoneKey === 'art' ? 'ARTWORK' : 'TEXT'}
</span>
{/* Resize handle */}
<div
onPointerDown={startDrag(zoneKey, 'resize')}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
style={{
position: 'absolute',
right: -HANDLE / 2,
bottom: -HANDLE / 2,
width: HANDLE,
height: HANDLE,
backgroundColor: colors.border,
border: '2px solid #fff',
borderRadius: 3,
cursor: 'nwse-resize',
touchAction: 'none',
}}
/>
</div>
);
};
return (
<div style={{ overflow: 'auto', maxWidth: '100%' }}>
<div
style={{
position: 'relative',
width: CARD_W,
height: CARD_H,
backgroundColor: '#1a1a22',
backgroundImage: frameImageUrl ? `url(${frameImageUrl})` : undefined,
backgroundSize: '100% 100%',
borderRadius: 12,
overflow: 'hidden',
flexShrink: 0,
}}
>
{!frameImageUrl && (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#888',
fontSize: 13,
fontStyle: 'italic',
}}
>
Upload a frame image to position its windows
</div>
)}
{renderZone('art')}
{renderZone('text')}
</div>
</div>
);
}