mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
670c841866
- A shape and a page take a paint: one colour with its own alpha, a linear or radial gradient of two to six stops, or one of twelve patterns (checks, gingham, stripes, pinstripes, dots, polka dots, zigzag, waves, grid, scales, sunburst, confetti). fill, gradient and background keep the colour that stands for the paint, so an older reader still draws something close, and a plain colour or the old cover fade is stored in the old fields alone. - Gradients render as SVG in user space with ids per mounted copy, so the two halves of a spread in page mode never share a defs id; patterns are drawn out as geometry clipped to the outline and capped at 2000 primitives. - The new paint picker has none, solid, gradient and pattern tabs, a stop bar to click, drag and pull stops off, an angle dial, a radial centre pad and pattern tiles drawn by the page's own generator. - Every swatch row gains the book's own colours, colours read from the page's photos, recent colours and, where it applies, an opacity slider. - With nothing selected the inspector sets the page background, gives it to every page of its kind, and lays a photo under the whole page. - The assistant's background operation takes a paint as well as a colour, style and add set a shape's paint, and describe_book_page reads both. - BOOK_SCHEMA_REV goes to 2.
325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import type { BookPaint } from '@trek/shared';
|
|
import { useState } from 'react';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { StudioPaintPicker } from '../../../src/components/Studio/StudioPaintPicker';
|
|
import { AngleDial } from '../../../src/components/Studio/paint/AngleDial';
|
|
import { GradientStopBar } from '../../../src/components/Studio/paint/GradientStopBar';
|
|
import { RadialPad } from '../../../src/components/Studio/paint/RadialPad';
|
|
import type { PaintChannel } from '../../../src/components/Studio/paint/usePaintChannel';
|
|
import { fireEvent, render } from '../../helpers/render';
|
|
|
|
/** The value, or a failed test saying it was missing. */
|
|
function need<T>(value: T | null | undefined): T {
|
|
if (value == null) throw new Error('expected a value');
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* The paint picker, driven the way a person drives it: tabs, swatches, the
|
|
* stop bar, the dial. What is asserted is the paint it writes and whether a
|
|
* write is a live step inside a drag or the one that ends it.
|
|
*/
|
|
|
|
const t = (k: string, p?: Record<string, string | number>) => (p ? `${k}:${JSON.stringify(p)}` : k);
|
|
|
|
/** The picker over its own state, as the inspector holds it through the store. */
|
|
function Harness({ start, channel }: { start: BookPaint | null; channel: PaintChannel }) {
|
|
const [value, setValue] = useState<BookPaint | null>(start);
|
|
const write: PaintChannel = {
|
|
live: (p) => {
|
|
channel.live(p);
|
|
setValue(p);
|
|
},
|
|
commit: (p) => {
|
|
channel.commit(p);
|
|
setValue(p);
|
|
},
|
|
};
|
|
return <StudioPaintPicker value={value} channel={write} t={t} noneLabel="none" />;
|
|
}
|
|
|
|
const spy = () => ({ live: vi.fn(), commit: vi.fn() });
|
|
const last = (fn: ReturnType<typeof vi.fn>) => fn.mock.calls[fn.mock.calls.length - 1]?.[0] as BookPaint | null;
|
|
|
|
/** A rect for the bar, the dial or the pad: jsdom lays nothing out. */
|
|
function sized(el: Element, box = { left: 0, top: 0, width: 200, height: 24 }) {
|
|
el.getBoundingClientRect = () =>
|
|
({
|
|
...box,
|
|
right: box.left + box.width,
|
|
bottom: box.top + box.height,
|
|
x: box.left,
|
|
y: box.top,
|
|
toJSON: () => ({}),
|
|
}) as DOMRect;
|
|
}
|
|
|
|
const tab = (c: HTMLElement, label: string) =>
|
|
fireEvent.click(need([...c.querySelectorAll('.st-paint-tab')].find((b) => b.textContent === label)));
|
|
|
|
describe('the kind tabs', () => {
|
|
it('marks the kind the paint is, and offers no fill under its own name', () => {
|
|
const { container } = render(<Harness start={null} channel={spy()} />);
|
|
const tabs = [...container.querySelectorAll('.st-paint-tab')];
|
|
expect(tabs.map((b) => b.textContent)).toEqual([
|
|
'none',
|
|
'journey.studio.paint.solid',
|
|
'journey.studio.paint.gradient',
|
|
'journey.studio.paint.pattern',
|
|
]);
|
|
expect(tabs[0].getAttribute('aria-selected')).toBe('true');
|
|
});
|
|
|
|
it('keeps the colour across kinds, and brings a visited kind back as it was left', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={{ type: 'solid', color: '#1e3a8a' }} channel={channel} />);
|
|
|
|
tab(container, 'journey.studio.paint.pattern');
|
|
expect(last(channel.commit)).toMatchObject({ type: 'pattern', fg: '#1e3a8a' });
|
|
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.pattern.polka"]')));
|
|
expect(last(channel.commit)).toMatchObject({ type: 'pattern', pattern: 'polka' });
|
|
|
|
tab(container, 'journey.studio.paint.gradient');
|
|
expect(last(channel.commit)).toMatchObject({ type: 'linear', stops: [{ color: '#1e3a8a' }, {}] });
|
|
|
|
tab(container, 'journey.studio.paint.pattern');
|
|
expect(last(channel.commit)).toMatchObject({ type: 'pattern', pattern: 'polka' });
|
|
|
|
tab(container, 'none');
|
|
expect(last(channel.commit)).toBeNull();
|
|
expect(channel.live).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('one colour', () => {
|
|
it('writes the swatch picked, and its opacity live while dragged and once when let go', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={{ type: 'solid', color: '#111111' }} channel={channel} />);
|
|
fireEvent.click(need(container.querySelector('[aria-label="#c2410c"]')));
|
|
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c' });
|
|
|
|
const range = container.querySelector('.st-alpha input') as HTMLInputElement;
|
|
fireEvent.change(range, { target: { value: '40' } });
|
|
expect(last(channel.live)).toEqual({ type: 'solid', color: '#c2410c', alpha: 0.4 });
|
|
fireEvent.pointerUp(range);
|
|
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c', alpha: 0.4 });
|
|
|
|
fireEvent.change(range, { target: { value: '100' } });
|
|
fireEvent.pointerUp(range);
|
|
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c' });
|
|
});
|
|
});
|
|
|
|
describe('a gradient', () => {
|
|
const start: BookPaint = {
|
|
type: 'linear',
|
|
angle: 90,
|
|
stops: [
|
|
{ at: 0, color: '#000000' },
|
|
{ at: 1, color: '#ffffff' },
|
|
],
|
|
};
|
|
|
|
it('adds a stop where the bar is clicked, recolours the chosen stop and takes it away', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={start} channel={channel} />);
|
|
const bar = need(container.querySelector('.st-stopbar'));
|
|
sized(bar);
|
|
fireEvent.pointerDown(bar, { button: 0, clientX: 50 });
|
|
const added = last(channel.commit);
|
|
expect(added?.type === 'linear' && added.stops).toHaveLength(3);
|
|
expect(added?.type === 'linear' && added.stops[2]).toEqual({ at: 0.25, color: '#404040' });
|
|
expect(container.querySelectorAll('.st-stop')[2].getAttribute('aria-pressed')).toBe('true');
|
|
|
|
const stopPanel = need(container.querySelector('.st-paint-stop'));
|
|
fireEvent.click(need(stopPanel.querySelector('[aria-label="#0f766e"]')));
|
|
expect(last(channel.commit)).toMatchObject({ stops: [{}, {}, { at: 0.25, color: '#0f766e' }] });
|
|
|
|
fireEvent.click(need(stopPanel.querySelector('[aria-label="journey.studio.paint.removeStop"]')));
|
|
const removed = last(channel.commit);
|
|
expect(removed?.type === 'linear' && removed.stops).toHaveLength(2);
|
|
// Two stops left: the bin is off.
|
|
expect(
|
|
(container.querySelector('.st-paint-stop [aria-label="journey.studio.paint.removeStop"]') as HTMLButtonElement)
|
|
.disabled
|
|
).toBe(true);
|
|
});
|
|
|
|
it('reverses, turns radial and back, and sets the angle from the number field', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={start} channel={channel} />);
|
|
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.paint.reverse"]')));
|
|
expect(last(channel.commit)).toMatchObject({ stops: [{ at: 1 }, { at: 0 }] });
|
|
|
|
const radio = (label: string) =>
|
|
need([...container.querySelectorAll('.st-seg-item')].find((b) => b.textContent === label));
|
|
fireEvent.click(radio('journey.studio.paint.radial'));
|
|
expect(last(channel.commit)).toMatchObject({ type: 'radial', cx: 0.5, cy: 0.5, r: 1 });
|
|
expect(container.querySelector('.st-radial-pad')).not.toBeNull();
|
|
fireEvent.click(radio('journey.studio.paint.linear'));
|
|
expect(last(channel.commit)).toMatchObject({ type: 'linear', angle: 180 });
|
|
|
|
const field = container.querySelector('.st-num input') as HTMLInputElement;
|
|
fireEvent.change(field, { target: { value: '400' } });
|
|
fireEvent.blur(field);
|
|
expect(last(channel.commit)).toMatchObject({ angle: 359 });
|
|
});
|
|
});
|
|
|
|
describe('a pattern', () => {
|
|
const start: BookPaint = { type: 'pattern', pattern: 'dots', fg: '#111111', bg: '#ffffff', scale: 1, angle: 0 };
|
|
|
|
it('draws a tile for each repeat, turns stripes to the diagonal, and lets the ground go', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={start} channel={channel} />);
|
|
expect(container.querySelectorAll('.st-pattern-tile')).toHaveLength(12);
|
|
expect(need(container.querySelector('.st-pattern-tile[aria-checked="true"]')).getAttribute('aria-label')).toBe(
|
|
'journey.studio.pattern.dots'
|
|
);
|
|
|
|
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.pattern.stripes"]')));
|
|
expect(last(channel.commit)).toMatchObject({ pattern: 'stripes', angle: 45 });
|
|
|
|
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
|
expect(last(channel.commit)).toMatchObject({ bg: null });
|
|
expect(container.querySelectorAll('.st-pattern-tile.is-clear')).toHaveLength(12);
|
|
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
|
expect(last(channel.commit)).toMatchObject({ bg: '#ffffff' });
|
|
});
|
|
|
|
it('sizes the repeat on an even ratio scale, live and then once', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={start} channel={channel} />);
|
|
const range = container.querySelector('.st-paint-slider input') as HTMLInputElement;
|
|
// Scale 1 sits a little under halfway along 0.25 to 4.
|
|
expect(Number(range.value)).toBe(50);
|
|
fireEvent.change(range, { target: { value: '100' } });
|
|
expect(last(channel.live)).toMatchObject({ scale: 4 });
|
|
fireEvent.pointerUp(range);
|
|
expect(last(channel.commit)).toMatchObject({ scale: 4 });
|
|
expect(need(container.querySelector('.st-paint-value')).textContent).toBe('400%');
|
|
});
|
|
});
|
|
|
|
describe('the dial', () => {
|
|
it('turns with the pointer, snapping to the compass, and is one commit when let go', () => {
|
|
const onChange = vi.fn();
|
|
const { container } = render(<AngleDial value={0} onChange={onChange} label="Angle" />);
|
|
const dial = need(container.querySelector('.st-dial'));
|
|
sized(dial, { left: 0, top: 0, width: 40, height: 40 });
|
|
fireEvent.pointerDown(dial, { button: 0, clientX: 40, clientY: 20 });
|
|
expect(onChange).toHaveBeenLastCalledWith(90, false);
|
|
fireEvent.pointerMove(dial, { clientX: 20, clientY: 40 });
|
|
expect(onChange).toHaveBeenLastCalledWith(180, false);
|
|
fireEvent.pointerUp(dial);
|
|
expect(onChange).toHaveBeenLastCalledWith(180, true);
|
|
fireEvent.pointerMove(dial, { clientX: 0, clientY: 20 });
|
|
expect(onChange).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('takes the arrow keys, Shift for fifteen at a time', () => {
|
|
const onChange = vi.fn();
|
|
const { container } = render(<AngleDial value={350} onChange={onChange} label="Angle" />);
|
|
const dial = need(container.querySelector('.st-dial'));
|
|
expect(dial.getAttribute('aria-valuenow')).toBe('350');
|
|
fireEvent.keyDown(dial, { key: 'ArrowRight', shiftKey: true });
|
|
expect(onChange).toHaveBeenLastCalledWith(5, false);
|
|
fireEvent.keyUp(dial, { key: 'ArrowRight' });
|
|
expect(onChange).toHaveBeenLastCalledWith(5, true);
|
|
fireEvent.keyDown(dial, { key: 'a' });
|
|
expect(onChange).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('the stop bar', () => {
|
|
const paint: BookPaint & { type: 'linear' } = {
|
|
type: 'linear',
|
|
angle: 0,
|
|
stops: [
|
|
{ at: 0, color: '#000000' },
|
|
{ at: 0.5, color: '#888888' },
|
|
{ at: 1, color: '#ffffff' },
|
|
],
|
|
};
|
|
const labels = { bar: 'bar', stop: (pos: number) => `stop ${pos}` };
|
|
|
|
it('drags a stop along the bar live, and pulls it off to remove it', () => {
|
|
const onChange = vi.fn();
|
|
const onSelect = vi.fn();
|
|
const { container } = render(
|
|
<GradientStopBar paint={paint} selected={0} onSelect={onSelect} onChange={onChange} labels={labels} />
|
|
);
|
|
sized(need(container.querySelector('.st-stopbar')));
|
|
const stop = container.querySelectorAll('.st-stop')[1];
|
|
fireEvent.pointerDown(stop, { button: 0, clientX: 100 });
|
|
expect(onSelect).toHaveBeenCalledWith(1);
|
|
fireEvent.pointerMove(stop, { clientX: 150, clientY: 10 });
|
|
expect(onChange).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ stops: [paint.stops[0], { at: 0.75, color: '#888888' }, paint.stops[2]] }),
|
|
false
|
|
);
|
|
fireEvent.pointerMove(stop, { clientX: 150, clientY: 90 });
|
|
expect(stop.className).toContain('is-leaving');
|
|
fireEvent.pointerUp(stop);
|
|
const removed = onChange.mock.calls[onChange.mock.calls.length - 1];
|
|
expect(removed[1]).toBe(true);
|
|
expect(removed[0].stops).toHaveLength(2);
|
|
});
|
|
|
|
it('moves the stop with the arrow keys and removes it with Delete', () => {
|
|
const onChange = vi.fn();
|
|
const { container } = render(
|
|
<GradientStopBar paint={paint} selected={1} onSelect={() => {}} onChange={onChange} labels={labels} />
|
|
);
|
|
const stop = container.querySelectorAll('.st-stop')[1];
|
|
expect(stop.getAttribute('aria-label')).toBe('stop 50');
|
|
fireEvent.keyDown(stop, { key: 'ArrowLeft', shiftKey: true });
|
|
expect(onChange.mock.calls[0][0].stops[1].at).toBe(0.4);
|
|
fireEvent.keyDown(stop, { key: 'Delete' });
|
|
expect(onChange.mock.calls[1][0].stops).toHaveLength(2);
|
|
fireEvent.keyDown(stop, { key: 'x' });
|
|
expect(onChange).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not add a stop past six, or when the click lands on a stop', () => {
|
|
const onChange = vi.fn();
|
|
const six = { ...paint, stops: [0, 0.2, 0.4, 0.6, 0.8, 1].map((at) => ({ at, color: '#000000' })) };
|
|
const { container } = render(
|
|
<GradientStopBar paint={six} selected={0} onSelect={() => {}} onChange={onChange} labels={labels} />
|
|
);
|
|
const bar = need(container.querySelector('.st-stopbar'));
|
|
sized(bar);
|
|
fireEvent.pointerDown(bar, { button: 0, clientX: 30 });
|
|
fireEvent.pointerDown(need(container.querySelector('.st-stop')), { button: 0, clientX: 0 });
|
|
fireEvent.pointerUp(need(container.querySelector('.st-stop')));
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('the radial centre', () => {
|
|
it('moves the centre with the pointer and the keys', () => {
|
|
const onChange = vi.fn();
|
|
const radial = {
|
|
type: 'radial' as const,
|
|
cx: 0.5,
|
|
cy: 0.5,
|
|
r: 1,
|
|
stops: [
|
|
{ at: 0, color: '#ffffff' },
|
|
{ at: 1, color: '#000000' },
|
|
],
|
|
};
|
|
const { container } = render(<RadialPad paint={radial} onChange={onChange} label="Centre" />);
|
|
const pad = need(container.querySelector('.st-radial-pad'));
|
|
sized(pad, { left: 0, top: 0, width: 100, height: 50 });
|
|
fireEvent.pointerDown(pad, { button: 0, clientX: 25, clientY: 10 });
|
|
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 0.25, cy: 0.2 }), false);
|
|
fireEvent.pointerMove(pad, { clientX: 200, clientY: -5 });
|
|
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 1, cy: 0 }), false);
|
|
fireEvent.pointerUp(pad);
|
|
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 1, cy: 0 }), true);
|
|
fireEvent.keyDown(pad, { key: 'ArrowDown' });
|
|
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 0.5, cy: 0.52 }), true);
|
|
});
|
|
});
|