170 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
};
|