mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(studio): letters filled with a gradient or a pattern
This commit is contained in:
@@ -64,12 +64,19 @@ export function StudioPaintPicker({
|
||||
channel,
|
||||
t,
|
||||
noneLabel,
|
||||
kinds = KINDS,
|
||||
}: {
|
||||
value: BookPaint | null;
|
||||
channel: PaintChannel;
|
||||
t: StudioT;
|
||||
/** What "no paint" is called here: no fill on a shape, the paper on a page. */
|
||||
noneLabel: string;
|
||||
/**
|
||||
* The kinds offered as tabs; all four by default. With one, the row of tabs
|
||||
* is left out: the letters of a text pick gradient or pattern beside their
|
||||
* other fills and only need that kind's controls here.
|
||||
*/
|
||||
kinds?: readonly PaintKind[];
|
||||
}) {
|
||||
const kind = kindOf(value);
|
||||
// The last paint of each kind shown here, so a kind visited again comes back as it was left.
|
||||
@@ -96,28 +103,30 @@ export function StudioPaintPicker({
|
||||
|
||||
return (
|
||||
<div className="st-paint">
|
||||
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
|
||||
{KINDS.map((k) => (
|
||||
<button
|
||||
type="button"
|
||||
key={k}
|
||||
role="tab"
|
||||
aria-selected={k === kind}
|
||||
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
|
||||
onClick={() => choose(k)}
|
||||
>
|
||||
<span
|
||||
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
|
||||
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
|
||||
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
|
||||
aria-hidden
|
||||
{kinds.length > 1 && (
|
||||
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
|
||||
{kinds.map((k) => (
|
||||
<button
|
||||
type="button"
|
||||
key={k}
|
||||
role="tab"
|
||||
aria-selected={k === kind}
|
||||
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
|
||||
onClick={() => choose(k)}
|
||||
>
|
||||
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
|
||||
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
|
||||
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
|
||||
aria-hidden
|
||||
>
|
||||
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value?.type === 'solid' && (
|
||||
<Swatches
|
||||
|
||||
@@ -189,6 +189,32 @@ describe('the fill of the letters', () => {
|
||||
expect(screen.getByText('journey.studio.fx.focalHint')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('turns the letters into a gradient or a pattern from their own colour, edited as a shape is', () => {
|
||||
const plain = mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' }, color: '#0f766e' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.gradient' }));
|
||||
const gradient = plain.set.mock.calls.at(-1)?.[0]?.fillPaint;
|
||||
expect(gradient?.type).toBe('linear');
|
||||
expect(gradient?.stops[0]?.color).toBe('#0f766e');
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }));
|
||||
expect(plain.set.mock.calls.at(-1)?.[0]?.fillPaint?.type).toBe('pattern');
|
||||
plain.unmount();
|
||||
|
||||
const patterned = {
|
||||
type: 'pattern' as const,
|
||||
pattern: 'dots' as const,
|
||||
fg: '#d62828',
|
||||
bg: '#ffffff',
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
};
|
||||
mount(text({ fillPaint: patterned }));
|
||||
expect(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }).getAttribute('aria-checked')).toBe(
|
||||
'true'
|
||||
);
|
||||
// Only the pattern's own controls: the kind is picked in the row above, so the paint's tabs stay away.
|
||||
expect(screen.queryByRole('tablist')).toBeNull();
|
||||
});
|
||||
|
||||
it('says so, and offers no photo fill, when the journey has no photos', () => {
|
||||
mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' } }), []);
|
||||
expect(screen.getByText('journey.studio.fx.noPhotos')).toBeTruthy();
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { BookElement, BookSpread, BookTextElement } from '@trek/shared';
|
||||
import { bookTextIsDrawn } from '@trek/shared';
|
||||
import { bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
|
||||
|
||||
import '../../styles/studio-text.css';
|
||||
import { photoSrc } from './bookRender';
|
||||
import { FocalPad } from './inspector/FocalPad';
|
||||
import { textEffectsInUse, textPlacementInUse } from './inspector/inspectorModel';
|
||||
import { paintAs } from './paint/paintEdit';
|
||||
import type { PaintChannel } from './paint/usePaintChannel';
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls';
|
||||
import { StudioPaintPicker } from './StudioPaintPicker';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
|
||||
|
||||
@@ -275,10 +278,13 @@ function CurveControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
|
||||
);
|
||||
}
|
||||
|
||||
type FillKind = 'colour' | 'hollow' | 'photo';
|
||||
type FillKind = 'colour' | 'hollow' | 'photo' | 'gradient' | 'pattern';
|
||||
|
||||
function fillKindOf(el: BookTextElement): FillKind {
|
||||
if (el.fillPaint?.type === 'photo') return 'photo';
|
||||
const type = el.fillPaint?.type;
|
||||
if (type === 'photo') return 'photo';
|
||||
if (type === 'linear' || type === 'radial') return 'gradient';
|
||||
if (type === 'pattern') return 'pattern';
|
||||
return isHollow(el) ? 'hollow' : 'colour';
|
||||
}
|
||||
|
||||
@@ -294,9 +300,18 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
outline: el.outline ?? { ...defaultOutline(el), color: el.color },
|
||||
} as Patch);
|
||||
}
|
||||
if (next === 'gradient' || next === 'pattern') {
|
||||
// From the text's own colour, as a shape's paint turns from its solid fill.
|
||||
return set({ fillPaint: paintAs(next, { type: 'solid', color: el.color }) } as Patch);
|
||||
}
|
||||
const photoId = firstFillPhoto(spread, photos);
|
||||
if (photoId != null) set({ fillPaint: { type: 'photo', photoId, focalX: 0.5, focalY: 0.5, zoom: 1 } } as Patch);
|
||||
};
|
||||
const letterPaint = bookTextPaintFill(el);
|
||||
const channel: PaintChannel = {
|
||||
live: (paint) => live({ fillPaint: paint } as Patch),
|
||||
commit: (paint) => set({ fillPaint: paint } as Patch),
|
||||
};
|
||||
return (
|
||||
<div className="st-fx-group">
|
||||
<Line label={t('journey.studio.fx.letters')}>
|
||||
@@ -307,11 +322,22 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
{ value: 'colour' as const, label: t('journey.studio.colour') },
|
||||
{ value: 'hollow' as const, label: t('journey.studio.fx.hollow') },
|
||||
{ value: 'photo' as const, label: t('journey.studio.kind.photo'), disabled: !photos.length },
|
||||
{ value: 'gradient' as const, label: t('journey.studio.paint.gradient') },
|
||||
{ value: 'pattern' as const, label: t('journey.studio.paint.pattern') },
|
||||
]}
|
||||
onPick={pick}
|
||||
/>
|
||||
</Line>
|
||||
{!photos.length && <p className="st-hint st-fx-hint">{t('journey.studio.fx.noPhotos')}</p>}
|
||||
{letterPaint && (kind === 'gradient' || kind === 'pattern') && (
|
||||
<StudioPaintPicker
|
||||
value={letterPaint}
|
||||
channel={channel}
|
||||
t={t}
|
||||
noneLabel={t('journey.studio.colour')}
|
||||
kinds={[kind]}
|
||||
/>
|
||||
)}
|
||||
{photo && (
|
||||
<div className="st-fx-body">
|
||||
<div className="st-fx-photos" role="radiogroup" aria-label={t('journey.studio.fx.pickPhoto')}>
|
||||
|
||||
@@ -108,6 +108,40 @@ describe('a text drawn as SVG', () => {
|
||||
expect(Array.from(clip?.querySelectorAll('tspan') ?? []).map((t) => t.textContent)).toEqual(['LIS', 'BON']);
|
||||
});
|
||||
|
||||
it('fills the letters with a gradient through a gradient of their own frame', () => {
|
||||
const el = text({
|
||||
fillPaint: {
|
||||
type: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ at: 0, color: '#ff6600' },
|
||||
{ at: 1, color: '#ffcc00' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
|
||||
const gradient = container.querySelector('linearGradient');
|
||||
expect(gradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse');
|
||||
expect(Array.from(gradient?.querySelectorAll('stop') ?? []).map((s) => s.getAttribute('stop-color'))).toEqual([
|
||||
'#ff6600',
|
||||
'#ffcc00',
|
||||
]);
|
||||
const letters = container.querySelector('svg > text');
|
||||
expect(letters?.getAttribute('fill')).toBe(`url(#${gradient?.getAttribute('id')})`);
|
||||
});
|
||||
|
||||
it('fills the letters with a pattern, its ground and its marks clipped to them', () => {
|
||||
const el = text({
|
||||
fillPaint: { type: 'pattern', pattern: 'stripes', fg: '#d62828', bg: '#ffffff', scale: 1, angle: 0 },
|
||||
});
|
||||
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
|
||||
const clip = container.getElementsByTagName('clipPath')[0];
|
||||
const group = container.querySelector('g[clip-path]');
|
||||
expect(group?.getAttribute('clip-path')).toBe(`url(#${clip?.getAttribute('id')})`);
|
||||
expect(group?.querySelector('rect')?.getAttribute('fill')).toBe('#ffffff');
|
||||
expect(group?.querySelectorAll('path[fill="#d62828"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('gives every copy of a page its own ids, so a page drawn twice never clips to the other copy', () => {
|
||||
const el = text({ fillPaint: { type: 'photo', photoId: 12 }, curve: { type: 'circle' } });
|
||||
const { container } = render(
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { BookTextElement } from '@trek/shared';
|
||||
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn } from '@trek/shared';
|
||||
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
|
||||
|
||||
import { fontStack } from './bookFonts';
|
||||
import { photoSrc } from './bookRender';
|
||||
import { paintParts } from './paint/paintRender';
|
||||
import {
|
||||
caseStyle,
|
||||
curveAnchor,
|
||||
@@ -149,11 +150,16 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
|
||||
const reactId = useId();
|
||||
const pathId = svgId('tp-', reactId);
|
||||
const clipId = svgId('tc-', reactId);
|
||||
const paintId = svgId('tf-', reactId);
|
||||
const { w, h } = el.frame;
|
||||
const fontMm = ptToMm(el.size);
|
||||
const lines = bookDrawnLines(el);
|
||||
const curve = el.curve ? bookTextCurvePath(el.curve, w, h, fontMm) : null;
|
||||
const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null;
|
||||
// A gradient or pattern in the letters, laid out over the frame as a shape's paint is over its box.
|
||||
const fillPaint = bookTextPaintFill(el);
|
||||
const letterPaint = fillPaint ? paintParts(fillPaint, { x: 0, y: 0, w, h }, paintId) : null;
|
||||
const pattern = letterPaint?.pattern ?? null;
|
||||
const hollow = isHollow(el);
|
||||
const { outline, shadow } = el;
|
||||
|
||||
@@ -211,8 +217,20 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
|
||||
if (photo) {
|
||||
const cover = photoCover(w, h, photo);
|
||||
layers.push(<image key="photo" href={photoSrc(photo.photoId, big)} {...cover} clipPath={`url(#${clipId})`} />);
|
||||
} else if (pattern && letterPaint) {
|
||||
// The pattern's ground and its marks, clipped to the letters.
|
||||
layers.push(
|
||||
<g key="pattern" clipPath={`url(#${clipId})`}>
|
||||
{letterPaint.fill !== 'none' && <rect width={w} height={h} fill={letterPaint.fill} />}
|
||||
<g transform={pattern.transform}>
|
||||
{pattern.layers.map((layer, i) => (
|
||||
<path key={i} d={layer.d} fill={pattern.ink} fillOpacity={layer.opacity < 1 ? layer.opacity : undefined} />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
} else {
|
||||
layers.push(glyphs({ fill: letterFill(el) ?? el.color }, 'fill'));
|
||||
layers.push(glyphs({ fill: letterPaint ? letterPaint.fill : (letterFill(el) ?? el.color) }, 'fill'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -227,7 +245,8 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
|
||||
>
|
||||
<defs>
|
||||
{curve && <path id={pathId} d={curve.d} fill="none" />}
|
||||
{photo && (
|
||||
{letterPaint?.defs}
|
||||
{(photo || pattern) && (
|
||||
<clipPath id={clipId} clipPathUnits="userSpaceOnUse">
|
||||
{glyphs(null, 'clip')}
|
||||
</clipPath>
|
||||
|
||||
@@ -42,7 +42,7 @@ function Stops({ stops }: { stops: readonly BookPaintStop[] }) {
|
||||
}
|
||||
|
||||
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
|
||||
interface PaintParts {
|
||||
export interface PaintParts {
|
||||
defs: ReactNode;
|
||||
fill: string;
|
||||
fillOpacity?: number;
|
||||
@@ -50,7 +50,12 @@ interface PaintParts {
|
||||
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
|
||||
}
|
||||
|
||||
function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
|
||||
/**
|
||||
* What drawing a paint over a box takes: the gradient definition and the fill
|
||||
* to point at it, or a pattern's geometry to draw clipped to the outline over
|
||||
* its ground. A shape's outline and the letters of a text both draw from it.
|
||||
*/
|
||||
export function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return {
|
||||
|
||||
@@ -28,7 +28,7 @@ export function withAlpha(hex: string, alpha = 1): string {
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${Math.max(0, alpha)})`;
|
||||
}
|
||||
|
||||
/** The colour the letters are filled with, or null when a photograph fills them. */
|
||||
/** The colour the letters are filled with, or null when a photograph, a gradient or a pattern fills them. */
|
||||
export function letterFill(el: BookTextElement): string | null {
|
||||
const fill = el.fillPaint;
|
||||
if (!fill) return el.color;
|
||||
|
||||
@@ -68,32 +68,39 @@ type ObjectFactory = typeof z.object | typeof z.strictObject;
|
||||
function paintSchemas(obj: ObjectFactory, pattern: z.ZodType<BookPattern, string>) {
|
||||
const stop = obj({ at: z.number().min(0).max(1), color: hex, alpha: alpha.optional() });
|
||||
const stops = z.array(stop).min(2).max(MAX_PAINT_STOPS);
|
||||
const linear = obj({ type: z.literal('linear'), angle, stops });
|
||||
const radial = obj({
|
||||
type: z.literal('radial'),
|
||||
cx: z.number().min(0).max(1),
|
||||
cy: z.number().min(0).max(1),
|
||||
r: z.number().min(RADIAL_RADIUS.min).max(RADIAL_RADIUS.max),
|
||||
stops,
|
||||
});
|
||||
const patterned = obj({
|
||||
type: z.literal('pattern'),
|
||||
pattern,
|
||||
fg: hex,
|
||||
bg: hex.nullable(),
|
||||
scale: z.number().min(PATTERN_SCALE.min).max(PATTERN_SCALE.max),
|
||||
angle,
|
||||
});
|
||||
const paint = z.discriminatedUnion('type', [
|
||||
obj({ type: z.literal('solid'), color: hex, alpha: alpha.optional() }),
|
||||
obj({ type: z.literal('linear'), angle, stops }),
|
||||
obj({
|
||||
type: z.literal('radial'),
|
||||
cx: z.number().min(0).max(1),
|
||||
cy: z.number().min(0).max(1),
|
||||
r: z.number().min(RADIAL_RADIUS.min).max(RADIAL_RADIUS.max),
|
||||
stops,
|
||||
}),
|
||||
obj({
|
||||
type: z.literal('pattern'),
|
||||
pattern,
|
||||
fg: hex,
|
||||
bg: hex.nullable(),
|
||||
scale: z.number().min(PATTERN_SCALE.min).max(PATTERN_SCALE.max),
|
||||
angle,
|
||||
}),
|
||||
linear,
|
||||
radial,
|
||||
patterned,
|
||||
]);
|
||||
return { stop, paint };
|
||||
return { stop, paint, linear, radial, patterned };
|
||||
}
|
||||
|
||||
const docSchemas = paintSchemas(z.object, z.string().pipe(z.enum(BOOK_PATTERNS)));
|
||||
|
||||
export const bookPaintStopSchema = docSchemas.stop;
|
||||
export const bookPaintSchema = docSchemas.paint;
|
||||
/** The paints that are more than one colour, as the letters of a text take them too (book-text-effects.schema.ts). */
|
||||
export const bookLinearPaintSchema = docSchemas.linear;
|
||||
export const bookRadialPaintSchema = docSchemas.radial;
|
||||
export const bookPatternPaintSchema = docSchemas.patterned;
|
||||
export type BookPaint = z.infer<typeof bookPaintSchema>;
|
||||
export type BookPaintStop = z.infer<typeof bookPaintStopSchema>;
|
||||
export type BookGradientPaint = Extract<BookPaint, { type: 'linear' | 'radial' }>;
|
||||
|
||||
@@ -43,8 +43,10 @@ import type { BookDocument } from './book.schema';
|
||||
* - 9: text effects and fitting (`transform`, `vAlign`, `outline`, `shadow`, `highlight`,
|
||||
* `curve`, `fillPaint`, `fit`, `fitMax`, book-text-effects.schema.ts), text up to 400 pt and
|
||||
* tracking up to 1.5 em.
|
||||
* - 10: letters filled with a gradient or a pattern (`fillPaint` of type `linear`, `radial`,
|
||||
* `pattern`, book-text-effects.schema.ts).
|
||||
*/
|
||||
export const BOOK_SCHEMA_REV = 9;
|
||||
export const BOOK_SCHEMA_REV = 10;
|
||||
|
||||
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
|
||||
export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer';
|
||||
|
||||
@@ -10,10 +10,19 @@ const n = (v: number) => `${Math.round(v * 10) / 10}`;
|
||||
|
||||
const CASES: Record<string, string> = { upper: 'capitals', lower: 'lower case', smallcaps: 'small caps' };
|
||||
|
||||
function stopWords(stops: readonly { color: string }[]): string {
|
||||
return `from ${stops[0]?.color ?? ''} to ${stops[stops.length - 1]?.color ?? ''}`;
|
||||
}
|
||||
|
||||
function fillWords(fill: NonNullable<BookTextEffects['fillPaint']>): string {
|
||||
if (fill.type === 'photo') {
|
||||
return `letters filled with photo ${fill.photoId} (focal ${fill.focalX.toFixed(2)}/${fill.focalY.toFixed(2)}, zoom ${n(fill.zoom)})`;
|
||||
}
|
||||
if (fill.type === 'linear') return `letters in a linear gradient at ${n(fill.angle)} deg, ${stopWords(fill.stops)}`;
|
||||
if (fill.type === 'radial') return `letters in a radial gradient, ${stopWords(fill.stops)}`;
|
||||
if (fill.type === 'pattern') {
|
||||
return `letters patterned ${fill.pattern} in ${fill.fg} on ${fill.bg ?? 'clear'} at scale ${n(fill.scale)}`;
|
||||
}
|
||||
return fill.alpha === 0 ? 'hollow letters (outline only)' : `fill ${fill.color} at alpha ${n(fill.alpha)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { bookTextIsDrawn } from './book-text-effects.schema';
|
||||
import { bookTextEffectWords } from './book-text-describe';
|
||||
import { bookTextIsDrawn, bookTextPaintFill } from './book-text-effects.schema';
|
||||
import { bookTextHasOwnGround } from './book-text-findings';
|
||||
import { bookTextElementSchema } from './book.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -75,3 +77,36 @@ describe('the effect fields of a text', () => {
|
||||
expect(bookTextIsDrawn(parse({ fillPaint: { type: 'photo', photoId: 3 } }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('letters filled with a gradient or a pattern', () => {
|
||||
const gradient = {
|
||||
type: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ at: 0, color: '#ff6600' },
|
||||
{ at: 1, color: '#ffcc00' },
|
||||
],
|
||||
};
|
||||
const gingham = { type: 'pattern', pattern: 'gingham', fg: '#d62828', bg: '#ffffff', scale: 1, angle: 0 };
|
||||
|
||||
it('BKFX-005: reads a gradient and a pattern in the shape a shape takes them, and draws them as SVG', () => {
|
||||
const lin = parse({ fillPaint: gradient });
|
||||
const pat = parse({ fillPaint: gingham });
|
||||
expect(bookTextPaintFill(lin)).toEqual(gradient);
|
||||
expect(bookTextPaintFill(pat)).toEqual(gingham);
|
||||
expect(bookTextIsDrawn(lin)).toBe(true);
|
||||
expect(bookTextIsDrawn(pat)).toBe(true);
|
||||
expect(bookTextPaintFill(parse({ fillPaint: { type: 'photo', photoId: 3 } }))).toBeNull();
|
||||
expect(() => parse({ fillPaint: { ...gingham, pattern: 'plaid' } })).toThrow();
|
||||
});
|
||||
|
||||
it('BKFX-006: names the paint for a model that cannot see the page, and judges no contrast against it', () => {
|
||||
expect(bookTextEffectWords({ ...parse({ fillPaint: gradient }), size: 40 })).toContain(
|
||||
'linear gradient at 90 deg, from #ff6600 to #ffcc00',
|
||||
);
|
||||
expect(bookTextEffectWords({ ...parse({ fillPaint: gingham }), size: 40 })).toContain(
|
||||
'patterned gingham in #d62828 on #ffffff',
|
||||
);
|
||||
expect(bookTextHasOwnGround(parse({ fillPaint: gingham }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
bookLinearPaintSchema,
|
||||
bookPatternPaintSchema,
|
||||
bookRadialPaintSchema,
|
||||
type BookPaint,
|
||||
} from './book-paint.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
@@ -27,10 +34,11 @@ import { z } from 'zod';
|
||||
*
|
||||
* ── The fill and the paint system ──────────────────────────────────────────
|
||||
*
|
||||
* `fillPaint` takes a solid colour with its own alpha or a photograph today.
|
||||
* Its solid variant has the shape the book's paint type gives a solid fill, so
|
||||
* gradients and patterns join this union as further members, without a
|
||||
* reader of today's fields changing.
|
||||
* `fillPaint` takes a solid colour with its own alpha, a photograph, or one
|
||||
* of the book's paints of more than one colour: a linear or radial gradient or
|
||||
* a pattern, in exactly the shape a shape or a page takes them, so one picker
|
||||
* and one renderer serve all three. A gradient or pattern fill is drawn as SVG
|
||||
* like a photo fill, the paint clipped to the letters.
|
||||
*/
|
||||
|
||||
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
|
||||
@@ -84,7 +92,10 @@ export const bookTextCurveSchema = z.object({
|
||||
});
|
||||
export type BookTextCurve = z.infer<typeof bookTextCurveSchema>;
|
||||
|
||||
/** What fills the letters in place of `color`: a colour with its own alpha (0 leaves an outline only), or a photograph. */
|
||||
/**
|
||||
* What fills the letters in place of `color`: a colour with its own alpha (0
|
||||
* leaves an outline only), a photograph, or a gradient or pattern.
|
||||
*/
|
||||
export const bookTextFillSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('solid'), color: hex, alpha: alpha.default(1) }),
|
||||
z.object({
|
||||
@@ -95,9 +106,18 @@ export const bookTextFillSchema = z.discriminatedUnion('type', [
|
||||
focalY: z.number().min(0).max(1).default(0.5),
|
||||
zoom: z.number().min(1).max(4).default(1),
|
||||
}),
|
||||
bookLinearPaintSchema,
|
||||
bookRadialPaintSchema,
|
||||
bookPatternPaintSchema,
|
||||
]);
|
||||
export type BookTextFill = z.infer<typeof bookTextFillSchema>;
|
||||
|
||||
/** The letters' gradient or pattern, as the paint a shape would take, or null for any other fill. */
|
||||
export function bookTextPaintFill(el: { fillPaint?: BookTextFill | null }): BookPaint | null {
|
||||
const fill = el.fillPaint;
|
||||
return fill && (fill.type === 'linear' || fill.type === 'radial' || fill.type === 'pattern') ? fill : null;
|
||||
}
|
||||
|
||||
/** `shrink` sets the text at the largest size up to `fitMax` that holds the frame, solved on edit and stored in `size`. */
|
||||
export const BOOK_TEXT_FITS = ['none', 'shrink'] as const;
|
||||
|
||||
@@ -133,10 +153,10 @@ export interface BookTextEffects {
|
||||
|
||||
/**
|
||||
* Whether a text is drawn as SVG rather than as a box of HTML type: on a
|
||||
* curve, or filled with a photograph. Such a text is set line by line as
|
||||
* written, never wrapped, which is what word art wants and what the checks
|
||||
* measure it by.
|
||||
* curve, or filled with a photograph, a gradient or a pattern. Such a text is
|
||||
* set line by line as written, never wrapped, which is what word art wants and
|
||||
* what the checks measure it by.
|
||||
*/
|
||||
export function bookTextIsDrawn(el: BookTextEffects): boolean {
|
||||
return !!el.curve || el.fillPaint?.type === 'photo';
|
||||
return !!el.curve || el.fillPaint?.type === 'photo' || !!bookTextPaintFill(el);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ const MIN_FILL_PX_PER_MM = 200 / 25.4;
|
||||
*/
|
||||
export function bookTextHasOwnGround(el: BookTextElement): boolean {
|
||||
if (el.highlight) return true;
|
||||
if (el.fillPaint && (el.fillPaint.type === 'photo' || el.fillPaint.alpha < 0.5)) return true;
|
||||
// A photo, a gradient or a pattern in the letters is no one colour the page could be judged against.
|
||||
if (el.fillPaint && (el.fillPaint.type !== 'solid' || el.fillPaint.alpha < 0.5)) return true;
|
||||
return !!el.outline && el.outline.width >= ptToMm(el.size) * 0.04;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ export const TEXT_EFFECT_PROPS = {
|
||||
shadow: f.shadow.describe('Hard, never blurred; dx, dy in mm.'),
|
||||
highlight: f.highlight.describe('A marker band behind each line; pads in mm.'),
|
||||
curve: f.curve.describe('One line on an arc (bend > 0 arches up) or round a circle (start: degrees, 0 top).'),
|
||||
fillPaint: f.fillPaint.describe('Replaces color: solid (alpha 0 is hollow, with an outline) or a gallery photo.'),
|
||||
fillPaint: f.fillPaint.describe(
|
||||
'Replaces color: solid (alpha 0 is hollow, with an outline), a gallery photo, or a linear/radial gradient or pattern shaped as on a shape.',
|
||||
),
|
||||
fit: f.fit.describe('shrink: the largest size up to size that holds the frame.'),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user