diff --git a/craft/src/panels/right/styles/ImagePickModal.test.tsx b/craft/src/panels/right/styles/ImagePickModal.test.tsx new file mode 100644 index 0000000..3de1e5c --- /dev/null +++ b/craft/src/panels/right/styles/ImagePickModal.test.tsx @@ -0,0 +1,88 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +vi.mock('../../../utils/assets', () => ({ listAssets: vi.fn(), uploadAsset: vi.fn() })); +vi.mock('../../../utils/image-sample', async () => { + const actual = await vi.importActual('../../../utils/image-sample'); + return { ...actual, loadImageForSampling: vi.fn() }; +}); +import { listAssets } from '../../../utils/assets'; +import { loadImageForSampling, CROSS_ORIGIN_MESSAGE } from '../../../utils/image-sample'; +import { ImagePickModal } from './ImagePickModal'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +const listMock = listAssets as unknown as ReturnType; +const loadMock = loadImageForSampling as unknown as ReturnType; + +// 2x1 image: left red, right transparent +const imageData = { width: 2, height: 1, data: new Uint8ClampedArray([255, 0, 0, 255, 0, 0, 0, 0]) }; +const okImage = () => ({ ok: true, width: 2, height: 1, imageData, draw: vi.fn() }); + +let container: HTMLDivElement; +let root: Root; +async function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + await act(async () => { root = createRoot(container); root.render(ui); }); + await act(async () => {}); +} +afterEach(() => { act(() => root.unmount()); container.remove(); vi.clearAllMocks(); }); +beforeEach(() => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); listMock.mockResolvedValue([{ name: 'a.png', url: '/a.png', type: 'image/png' }]); }); +const q = (id: string) => document.querySelector(`[data-testid="${id}"]`) as HTMLElement | null; + +function mouse(el: HTMLElement, type: string, clientX: number) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 50, right: 100, bottom: 50, x: 0, y: 0, toJSON() {} }) as DOMRect; + act(() => { el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX, clientY: 10 })); }); +} + +describe('ImagePickModal', () => { + test('lists uploaded assets and loads the chosen one', async () => { + loadMock.mockResolvedValue(okImage()); + await render(); + expect(q('image-asset-a.png')).toBeTruthy(); + expect(loadMock).not.toHaveBeenCalled(); + await act(async () => { q('image-asset-a.png')!.click(); }); + expect(loadMock).toHaveBeenCalledWith('/a.png'); + expect(q('image-pick-canvas')).toBeTruthy(); + }); + + test('initialSrc loads immediately; hover shows hex; click picks and closes', async () => { + loadMock.mockResolvedValue(okImage()); + const onPick = vi.fn(), onClose = vi.fn(); + await render(); + expect(loadMock).toHaveBeenCalledWith('/sel.png'); + const canvas = q('image-pick-canvas')!; + mouse(canvas, 'mousemove', 10); + expect(q('image-pick-hex')!.textContent).toBe('#ff0000'); + mouse(canvas, 'click', 10); + expect(onPick).toHaveBeenCalledWith('#ff0000'); + expect(onClose).toHaveBeenCalled(); + }); + + test('clicking a transparent pixel does not pick', async () => { + loadMock.mockResolvedValue(okImage()); + const onPick = vi.fn(), onClose = vi.fn(); + await render(); + mouse(q('image-pick-canvas')!, 'click', 90); + expect(onPick).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(container.ownerDocument.body.textContent).toContain('transparent'); + }); + + test('loader failure is shown as role=alert', async () => { + loadMock.mockResolvedValue({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }); + await render(); + const alert = document.querySelector('[role="alert"]'); + expect(alert?.textContent).toBe(CROSS_ORIGIN_MESSAGE); + expect(q('image-pick-canvas')).toBeNull(); + }); + + test('Escape closes', async () => { + const onClose = vi.fn(); + await render(); + act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); }); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/craft/src/panels/right/styles/ImagePickModal.tsx b/craft/src/panels/right/styles/ImagePickModal.tsx new file mode 100644 index 0000000..34614ac --- /dev/null +++ b/craft/src/panels/right/styles/ImagePickModal.tsx @@ -0,0 +1,169 @@ +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({ 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 }) => ( +
+ {assets.map((a) => ( +
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'}` }} + > + {a.name} +
+ ))} + {assets.length === 0 && ( +

+ No images uploaded yet. Upload one from an Image block first. +

+ )} +
+); + +/** Uploaded images for the picker, loaded once on mount. */ +export function useImageAssets(): Asset[] { + const [assets, setAssets] = useState([]); + 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 = ({ onPick, onClose, initialSrc }) => { + const assets = useImageAssets(); + const [src, setSrc] = useState(initialSrc || null); + const state = useSampledImage(src); + const canvasRef = useRef(null); + const zoomRef = useRef(null); + const [hover, setHover] = useState(''); + const [note, setNote] = useState(''); + + useEffect(() => { + if (state.status === 'ready' && canvasRef.current) state.img.draw(canvasRef.current); + }, [state]); + + const locate = (e: React.MouseEvent) => { + 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) => { + 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) => { + 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 ( + }> +
+
+

Pick a colour from an image

+ +
+ + {state.status === 'loading' &&

Loading image...

} + {state.status === 'failed' &&
{state.message}
} + {src && state.status !== 'failed' && ( +
+ 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' && ( +
+
+ )} + {note &&

{note}

} +
+ )} + {!src &&

Choose an uploaded image above, then click a spot in it to use that colour.

} +
+
+ ); +}; diff --git a/craft/src/panels/right/styles/PaletteFromImageModal.test.tsx b/craft/src/panels/right/styles/PaletteFromImageModal.test.tsx new file mode 100644 index 0000000..2316bd7 --- /dev/null +++ b/craft/src/panels/right/styles/PaletteFromImageModal.test.tsx @@ -0,0 +1,100 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +vi.mock('../../../utils/assets', () => ({ listAssets: vi.fn(), uploadAsset: vi.fn() })); +vi.mock('../../../utils/image-sample', async () => { + const actual = await vi.importActual('../../../utils/image-sample'); + return { ...actual, loadImageForSampling: vi.fn() }; +}); +import { listAssets } from '../../../utils/assets'; +import { loadImageForSampling, CROSS_ORIGIN_MESSAGE } from '../../../utils/image-sample'; +import { PaletteFromImageModal } from './PaletteFromImageModal'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +const listMock = listAssets as unknown as ReturnType; +const loadMock = loadImageForSampling as unknown as ReturnType; + +// 10 px: 5 red, 3 blue, 2 green +function image() { + const px: number[] = []; + const push = (c: number[], n: number) => { for (let i = 0; i < n; i++) px.push(...c, 255); }; + push([255, 0, 0], 5); push([0, 0, 255], 3); push([0, 160, 0], 2); + return { ok: true, width: 10, height: 1, imageData: { width: 10, height: 1, data: new Uint8ClampedArray(px) }, draw: vi.fn() }; +} + +let container: HTMLDivElement; +let root: Root; +async function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + await act(async () => { root = createRoot(container); root.render(ui); }); + await act(async () => {}); +} +afterEach(() => { act(() => root.unmount()); container.remove(); vi.clearAllMocks(); }); +beforeEach(() => { listMock.mockResolvedValue([{ name: 'a.png', url: '/a.png', type: 'image/png' }]); }); +const q = (id: string) => document.querySelector(`[data-testid="${id}"]`) as HTMLButtonElement | null; +const click = (el: HTMLElement | null) => act(() => { el!.click(); }); + +describe('PaletteFromImageModal', () => { + test('nothing is applied automatically', async () => { + loadMock.mockResolvedValue(image()); + const onAdd = vi.fn(); + await render(); + expect(q('palette-row-#ff0000')).toBeTruthy(); + expect(onAdd).not.toHaveBeenCalled(); + }); + + test('swatches in population order, Add calls onAdd then shows Added', async () => { + loadMock.mockResolvedValue(image()); + const onAdd = vi.fn(); + await render(); + const rows = [...document.querySelectorAll('[data-testid^="palette-row-"]')].map((r) => r.getAttribute('data-testid')); + expect(rows).toEqual(['palette-row-#ff0000', 'palette-row-#0000ff', 'palette-row-#00a000']); + click(q('palette-add-#0000ff')); + expect(onAdd).toHaveBeenCalledWith('#0000ff'); + expect(q('palette-add-#0000ff')!.disabled).toBe(true); + expect(q('palette-add-#0000ff')!.textContent).toBe('Added'); + }); + + test('existing hexes are disabled / Added (case-insensitive)', async () => { + loadMock.mockResolvedValue(image()); + await render(); + expect(q('palette-add-#ff0000')!.disabled).toBe(true); + expect(q('palette-add-#ff0000')!.textContent).toBe('Added'); + expect(q('palette-add-#0000ff')!.disabled).toBe(false); + }); + + test('Add all adds only the not-yet-added colours', async () => { + loadMock.mockResolvedValue(image()); + const onAdd = vi.fn(); + await render(); + click(q('palette-add-all')); + expect(onAdd.mock.calls.map((c) => c[0])).toEqual(['#0000ff', '#00a000']); + expect(q('palette-add-all')!.disabled).toBe(true); + }); + + test('picking a listed asset loads it', async () => { + loadMock.mockResolvedValue(image()); + await render(); + expect(q('palette-row-#ff0000')).toBeNull(); + await act(async () => { q('image-asset-a.png')!.click(); }); + expect(loadMock).toHaveBeenCalledWith('/a.png'); + expect(q('palette-row-#ff0000')).toBeTruthy(); + }); + + test('loader failure shown as role=alert, no swatches', async () => { + loadMock.mockResolvedValue({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }); + await render(); + expect(document.querySelector('[role="alert"]')?.textContent).toBe(CROSS_ORIGIN_MESSAGE); + expect(document.querySelector('[data-testid^="palette-row-"]')).toBeNull(); + }); + + test('Escape closes', async () => { + const onClose = vi.fn(); + await render(); + act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); }); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/craft/src/panels/right/styles/PaletteFromImageModal.tsx b/craft/src/panels/right/styles/PaletteFromImageModal.tsx new file mode 100644 index 0000000..9db32e3 --- /dev/null +++ b/craft/src/panels/right/styles/PaletteFromImageModal.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Modal } from '../../../ui/Modal'; +import { extractPalette } from '../../../utils/palette-extract'; +import { + ImageAssetList, useImageAssets, useSampledImage, + modalPanelStyle, modalTitleStyle, modalBtnStyle, errorStyle, +} from './ImagePickModal'; + +export interface PaletteFromImageModalProps { + onAdd: (hex: string, label?: string) => void; + onClose: () => void; + existingHexes: string[]; + /** The selected image's src, offered as the starting image. */ + initialSrc?: string; +} + +export const PaletteFromImageModal: React.FC = ({ onAdd, onClose, existingHexes, initialSrc }) => { + const assets = useImageAssets(); + const [src, setSrc] = useState(initialSrc || null); + const [count, setCount] = useState(6); + const [added, setAdded] = useState([]); + const state = useSampledImage(src); + + useEffect(() => { setAdded([]); }, [src]); + + const colours = useMemo( + () => (state.status === 'ready' ? extractPalette(state.img.imageData.data, { count }) : []), + [state, count], + ); + const taken = useMemo(() => new Set([...existingHexes, ...added].map((h) => h.toLowerCase())), [existingHexes, added]); + + const add = (hex: string) => { + onAdd(hex); + setAdded((a) => [...a, hex]); + }; + const addAll = () => colours.filter((h) => !taken.has(h)).forEach(add); + const remaining = colours.filter((h) => !taken.has(h)).length; + + return ( + }> +
+
+

Palette from an image

+ +
+ + {!src &&

Choose an uploaded image to see its main colours. Nothing is added until you press Add.

} + {state.status === 'loading' &&

Reading colours...

} + {state.status === 'failed' &&
{state.message}
} + {state.status === 'ready' && ( +
+
+ + + + +
+ {colours.length === 0 &&

No colours found. This image may be fully transparent.

} + {colours.map((hex) => { + const done = taken.has(hex); + return ( +
+ + {hex} + +
+ ); + })} +
+ )} +
+
+ ); +};