Adds DEFAULT_LAYOUT + IMAGE_FRAME_ROWS constants (fractional art/text window anchors), a ZoneEditor component for bounding-box layout editing in the designer, and expands the custom-frames/games CRUD API surface to support frame image storage and retrieval. Custom card designer pages wire these together with the existing PNG export pipeline. See .convoys/card-designer-image-frames.md for scope tracking.
161 lines
4.5 KiB
JavaScript
161 lines
4.5 KiB
JavaScript
/* 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>
|
|
);
|
|
}
|