Files
site-builder/craft/src/panels/right/styles/ImagePickModal.tsx
T

170 lines
7.5 KiB
TypeScript

import React, { useEffect, useRef, useState, CSSProperties } from 'react';
import { Modal } from '../../../ui/Modal';
import { listAssets, Asset } from '../../../utils/assets';
import { clickableProps } from '../../../utils/a11y';
import { loadImageForSampling, sampleAt, SampledImage } from '../../../utils/image-sample';
export type SampleState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; img: SampledImage }
| { status: 'failed'; message: string };
/** Load `src` for pixel reading; re-runs when `src` changes, ignoring stale results. */
export function useSampledImage(src: string | null): SampleState {
const [state, setState] = useState<SampleState>({ status: 'idle' });
useEffect(() => {
if (!src) { setState({ status: 'idle' }); return; }
let live = true;
setState({ status: 'loading' });
loadImageForSampling(src).then((r) => {
if (!live) return;
setState(r.ok ? { status: 'ready', img: r } : { status: 'failed', message: r.message });
});
return () => { live = false; };
}, [src]);
return state;
}
/** Uploaded-image thumbnails; `selected` highlights the current one. */
export const ImageAssetList: React.FC<{
assets: Asset[];
selected: string | null;
onSelect: (url: string) => void;
}> = ({ assets, selected, onSelect }) => (
<div data-testid="image-assets" style={assetGridStyle}>
{assets.map((a) => (
<div
key={a.url}
{...clickableProps(() => onSelect(a.url))}
data-testid={`image-asset-${a.name}`}
title={a.name}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', aspectRatio: '1', border: `2px solid ${a.url === selected ? '#3b82f6' : 'transparent'}` }}
>
<img src={a.url} alt={a.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
))}
{assets.length === 0 && (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>
No images uploaded yet. Upload one from an Image block first.
</p>
)}
</div>
);
/** Uploaded images for the picker, loaded once on mount. */
export function useImageAssets(): Asset[] {
const [assets, setAssets] = useState<Asset[]>([]);
useEffect(() => {
let live = true;
listAssets('image').then((a) => { if (live) setAssets(a); }).catch(() => {});
return () => { live = false; };
}, []);
return assets;
}
export const modalPanelStyle: CSSProperties = {
background: '#18181b', border: '1px solid #3f3f46', borderRadius: 8, padding: 16,
color: '#e4e4e7', width: 460, maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto', fontSize: 12,
};
export const modalTitleStyle: CSSProperties = { fontSize: 15, fontWeight: 600, margin: 0 };
export const modalBtnStyle: CSSProperties = {
padding: '6px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#27272a', color: '#e4e4e7',
};
export const errorStyle: CSSProperties = {
margin: '10px 0 0', padding: '8px 10px', borderRadius: 4, background: '#3f1d1d', border: '1px solid #7f1d1d', color: '#fecaca', fontSize: 12,
};
const assetGridStyle: CSSProperties = {
maxHeight: 140, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 4, background: '#09090b', borderRadius: 6, padding: 4,
};
export interface ImagePickModalProps {
onPick: (hex: string) => void;
onClose: () => void;
/** The selected image's src, offered as the starting image. */
initialSrc?: string;
}
const ZOOM = 9;
export const ImagePickModal: React.FC<ImagePickModalProps> = ({ onPick, onClose, initialSrc }) => {
const assets = useImageAssets();
const [src, setSrc] = useState<string | null>(initialSrc || null);
const state = useSampledImage(src);
const canvasRef = useRef<HTMLCanvasElement>(null);
const zoomRef = useRef<HTMLCanvasElement>(null);
const [hover, setHover] = useState<string>('');
const [note, setNote] = useState('');
useEffect(() => {
if (state.status === 'ready' && canvasRef.current) state.img.draw(canvasRef.current);
}, [state]);
const locate = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (state.status !== 'ready') return null;
const rect = e.currentTarget.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
return {
x: Math.floor(((e.clientX - rect.left) / rect.width) * state.img.width),
y: Math.floor(((e.clientY - rect.top) / rect.height) * state.img.height),
};
};
const onMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const p = locate(e);
if (!p || state.status !== 'ready') return;
setHover(sampleAt(state.img.imageData, p.x, p.y));
setNote('');
const z = zoomRef.current?.getContext?.('2d');
if (z && canvasRef.current) {
z.imageSmoothingEnabled = false;
z.clearRect(0, 0, 72, 72);
z.drawImage(canvasRef.current, p.x - 4, p.y - 4, ZOOM, ZOOM, 0, 0, 72, 72);
}
};
const onClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const p = locate(e);
if (!p || state.status !== 'ready') return;
const hex = sampleAt(state.img.imageData, p.x, p.y);
if (!hex) { setNote('That spot is transparent. Click a coloured part of the image.'); return; }
onPick(hex);
onClose();
};
return (
<Modal open onClose={onClose} backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Pick a colour from an image' } as React.HTMLAttributes<HTMLDivElement>}>
<div style={modalPanelStyle} data-testid="image-pick-modal">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<h3 style={modalTitleStyle}>Pick a colour from an image</h3>
<button type="button" style={modalBtnStyle} onClick={onClose} data-testid="image-pick-close">Close</button>
</div>
<ImageAssetList assets={assets} selected={src} onSelect={setSrc} />
{state.status === 'loading' && <p style={{ color: '#a1a1aa' }}>Loading image...</p>}
{state.status === 'failed' && <div role="alert" style={errorStyle} data-testid="image-pick-error">{state.message}</div>}
{src && state.status !== 'failed' && (
<div style={{ marginTop: 10 }}>
<canvas
ref={canvasRef}
data-testid="image-pick-canvas"
onMouseMove={onMove}
onMouseLeave={() => setHover('')}
onClick={onClick}
style={{ display: state.status === 'ready' ? 'block' : 'none', maxWidth: '100%', maxHeight: 280, margin: '0 auto', cursor: 'crosshair', border: '1px solid #3f3f46', borderRadius: 4 }}
/>
{state.status === 'ready' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8 }}>
<canvas ref={zoomRef} width={72} height={72} aria-hidden="true" style={{ width: 72, height: 72, border: '1px solid #3f3f46', borderRadius: 4, imageRendering: 'pixelated', background: '#09090b' }} />
<span data-testid="image-pick-swatch" style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: hover || 'transparent' }} />
<code data-testid="image-pick-hex" style={{ fontSize: 13 }}>{hover || 'Move over the image'}</code>
</div>
)}
{note && <p style={{ color: '#fbbf24', margin: '6px 0 0' }}>{note}</p>}
</div>
)}
{!src && <p style={{ color: '#a1a1aa', marginTop: 10 }}>Choose an uploaded image above, then click a spot in it to use that colour.</p>}
</div>
</Modal>
);
};