feat(studio): text effects, fit to frame and 24 lockups in a Text styles shelf

- A text can be cased (capitals, lower case, small caps), placed at the top,
  middle or foot of its frame, outlined, given a hard shadow or a marker band,
  bent on an arc or round a circle, filled with a gallery photo or left
  hollow, and shrunk to fit its frame. Every effect prints as vector: copies
  and strokes for HTML type, textPath and a clip over an <image> for SVG.
- Fitting is solved when the text is edited, in the Studio and in the edit
  engine, and stored; the chosen size is kept as the ceiling.
- Text sizes reach 400 pt and tracking 1.5 em.
- The Elements panel opens on a Text tab: the plain styles and 24 lockups
  (titles, badges and stamps, chapter marks, quotes, captions, places), each
  drawn with the book's own title and placed as one group.
- The assistant sets every effect through style and add, places a lockup with
  add_lockup, reads the effects in describe_book_page and gets two new
  findings (curve_overflow, effect_heavy) beside photo-fill and band checks.
- Book format revision 6.
This commit is contained in:
Maurice
2026-10-23 21:43:09 +02:00
parent 82b8eb1067
commit 75d0593198
125 changed files with 6469 additions and 51 deletions
@@ -60,6 +60,8 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
raster_effect: { labelKey: 'assistant.card.book.finding.raster_effect' },
hairline: { labelKey: 'assistant.card.book.finding.hairline' },
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
};
/** A page as the Studio names it: the cover, the first page, double page 3. */
@@ -9,6 +9,8 @@ import { HOLED_SHAPES } from './shapes'
import { hasEdge, shapeOutline } from './shapeOutline'
import { LineView } from './LineView'
import { TravelElementView } from './TravelElements'
import { TextEffectsView } from './TextEffectsView'
import { hasTextEffects } from './textEffects'
import { PageNumbers } from './PageNumbers'
import { PaintedShapeView } from './paint/PaintedShape'
import { SpreadPaint } from './paint/paintRender'
@@ -189,6 +191,9 @@ export function ElementView({
return <TravelElementView el={el} frameStyle={frameStyle(el)} big={big} />
}
// Outlines, shadows, bands, curves and fills draw in their own file; a plain text stays here, as it always drew.
if (hasTextEffects(el)) return <TextEffectsView el={el} frameStyle={frameStyle(el)} big={big} />
return (
<div
style={{
@@ -9,6 +9,8 @@ import { StudioLineShelf } from './StudioLineShelf'
import { PanelHead } from './StudioPanelHead'
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
import '../../styles/studio-elements.css'
import { StudioTextStyles } from './StudioTextStyles'
import type { ShelfSource } from './textStylesModel'
/**
* Everything you can add that does not come from the journey: type, shapes,
@@ -97,8 +99,55 @@ function IconCell({ name, onPick }: { name: string; onPick: (name: string) => vo
)
}
export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: string) => string }) {
const [tab, setTab] = useState<'shapes' | 'frames' | 'grids' | 'icons'>('shapes')
/** A plain text style: size, weight, its sample words and anything else it sets. */
type AddText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra?: Partial<BookElement>) => void
/** The type tab: the four plain styles, then the lockups of the Text styles shelf. */
function TextTab({ page, source, t, addText }: {
page: BookPageSetup
source: ShelfSource
t: (k: string) => string
addText: AddText
}) {
return (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.text')}</div>
<div className="st-stack">
<button type="button" className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
{t('journey.studio.styleTitle')}
</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
</button>
<button type="button"
className="st-tile"
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
>
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
{t('journey.studio.styleCaption')}
</span>
</button>
</div>
</div>
<StudioTextStyles page={page} source={source} t={t} />
</>
)
}
export function StudioElementsPanel({ page, t, source }: {
page: BookPageSetup
t: (k: string) => string
/** The journal, for the year and the point a lockup is filled with. */
source: ShelfSource
}) {
const [tab, setTab] = useState<'text' | 'shapes' | 'frames' | 'grids' | 'icons'>('text')
const [iconQuery, setIconQuery] = useState('')
/*
* How much of the library is on screen.
@@ -223,6 +272,9 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
<PanelHead label={t('journey.studio.elements')} />
<div className="st-tabs is-fluid">
<button type="button" className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
{t('journey.studio.text')}
</button>
<button type="button" className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}>
{t('journey.studio.shapes')}
</button>
@@ -238,33 +290,14 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
</div>
<div className="st-panel-scroll">
{/*
Type first: the plain styles, then the lockups, set titles and marks
drawn with the book's own words (StudioTextStyles.tsx).
*/}
{tab === 'text' && <TextTab page={page} source={source} t={t} addText={addText} />}
{tab === 'shapes' && (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.text')}</div>
<div className="st-stack">
<button type="button" className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
{t('journey.studio.styleTitle')}
</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
</button>
<button type="button"
className="st-tile"
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
>
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
{t('journey.studio.styleCaption')}
</span>
</button>
</div>
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.lines')}</div>
<div className="st-shape-grid">
@@ -13,13 +13,14 @@ import { StudioStickerInspector } from './StudioStickerInspector'
import { Swatches } from './StudioSwatches'
import { StudioBackgroundSection } from './StudioBackgroundSection'
import { StudioShapePaint } from './StudioShapePaint'
import { formatBookCoords, withThemeLinks } from '@trek/shared'
import { MAX_BOOK_TEXT_SIZE, formatBookCoords, withThemeLinks } from '@trek/shared'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
import { italicTitle } from './fontPicker'
import { PhotoCropExtras, PhotoLookSections } from './photo/StudioPhotoLook'
import { StudioTextEffects } from './StudioTextEffects'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
import { StudioArrangeSection } from './StudioArrangeSection'
@@ -368,7 +369,7 @@ export function StudioInspector({
</Line>
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField label={t('journey.studio.size')} value={el.size} min={4} max={200} step={0.5} unit="pt" onChange={v => set({ size: v })} />
<NumField label={t('journey.studio.size')} value={el.size} min={4} max={MAX_BOOK_TEXT_SIZE} step={0.5} unit="pt" onChange={v => set({ size: v })} />
<NumField label={t('journey.studio.leading')} value={el.leading} min={0.7} max={3} step={0.05} onChange={v => set({ leading: v })} />
</div>
<div style={{ marginTop: 10 }}>
@@ -381,7 +382,7 @@ export function StudioInspector({
label={t('journey.studio.tracking')}
value={el.tracking}
min={-0.2}
max={1}
max={1.5}
step={0.01}
unit="em"
onChange={v => set({ tracking: v })}
@@ -396,6 +397,8 @@ export function StudioInspector({
onLink={(token, color) => set({ color, token })} onUnlink={() => set({ token: undefined })} />
</Line>
</Section>
<StudioTextEffects el={el} set={set} live={live} photos={source.photos} spread={spread} t={t} />
</>
)}
@@ -739,7 +742,7 @@ export function StudioInspector({
* predict what "X 0.62" does to a photograph. Dragging the point over the actual
* image answers the question by showing it.
*/
function FocalPad({
export function FocalPad({
photoId, x, y, onDrag, onCommit, hint,
}: {
photoId: number
@@ -151,7 +151,7 @@ export function StudioSidebar({
<ContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload}
providers={providers} onBrowseProvider={onBrowseProvider} />
)}
{section === 'elements' && <StudioElementsPanel page={page} t={t} />}
{section === 'elements' && <StudioElementsPanel page={page} t={t} source={source} />}
{section === 'stickers' && <StudioStickersPanel page={page} t={t} />}
{section === 'travel' && (
<StudioTravelPanel
@@ -0,0 +1,177 @@
import { describe, expect, it, vi } from 'vitest';
import type { BookSpread, BookTextElement } from '@trek/shared';
import { bookTextElementSchema } from '@trek/shared';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { StudioTextEffects } from './StudioTextEffects';
/** The text inspector's effects: each switch arrives looking right, and each control sets one field. */
const t = (k: string) => k;
const text = (over: Record<string, unknown> = {}): BookTextElement =>
bookTextElementSchema.parse({
id: 't1',
kind: 'text',
frame: { x: 0, y: 0, w: 100, h: 40 },
text: 'Lisbon',
size: 72,
color: '#1a1a1a',
...over,
});
const spread = {
id: 'sp',
role: 'inner',
background: null,
parked: [],
entryId: null,
elements: [{ id: 'p1', kind: 'photo', photoId: 4 }],
} as unknown as BookSpread;
function mount(el: BookTextElement, photos = [{ photoId: 4 }, { photoId: 5 }]) {
const set = vi.fn();
const live = vi.fn();
const view = render(<StudioTextEffects el={el} set={set} live={live} photos={photos} spread={spread} t={t} />);
return { set, live, ...view };
}
/** The switch beside a label: the app's toggle, a button named by the label it carries. */
const toggle = (label: string) => screen.getByRole('button', { name: label });
/** A number field, named by its label, followed by its value and unit. */
const num = (label: string) => screen.getByRole('spinbutton', { name: (name) => name.startsWith(label) });
describe('case and placement', () => {
it('sets the case and where the lines sit', () => {
const { set } = mount(text());
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.caseSmall' }));
expect(set).toHaveBeenCalledWith({ transform: 'smallcaps' });
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.middle' }));
expect(set).toHaveBeenCalledWith({ vAlign: 'middle' });
});
it('shrinks to fit, and gives the chosen size back when fitting is turned off', () => {
const on = mount(text());
fireEvent.click(toggle('journey.studio.fx.fit'));
expect(on.set).toHaveBeenCalledWith({ fit: 'shrink' });
on.unmount();
const off = mount(text({ fit: 'shrink', size: 40, fitMax: 72 }));
expect(screen.getByText('journey.studio.fx.fitHint')).toBeTruthy();
fireEvent.click(toggle('journey.studio.fx.fit'));
expect(off.set).toHaveBeenCalledWith({ fit: 'none', size: 72 });
});
});
describe('effects', () => {
it('folds the effects away while a text has none, and opens them when it has one', () => {
mount(text());
expect(screen.queryByRole('button', { name: 'journey.studio.stroke' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /journey.studio.fx.effects/ }));
expect(toggle('journey.studio.stroke')).toBeTruthy();
});
it('switches an outline, a shadow and a band on in a colour that stands out, and off again', () => {
const { set } = mount(
text({
curve: null,
outline: null,
shadow: null,
highlight: null,
fillPaint: { type: 'solid', color: '#1a1a1a', alpha: 1 },
})
);
fireEvent.click(toggle('journey.studio.stroke'));
expect(set).toHaveBeenCalledWith({ outline: { color: '#ffffff', width: 1.27 } });
fireEvent.click(toggle('journey.studio.fx.shadow'));
expect(set).toHaveBeenCalledWith({ shadow: { dx: 2.03, dy: 2.03, color: '#ffffff', alpha: 1 } });
fireEvent.click(toggle('journey.studio.fx.band'));
expect(set).toHaveBeenCalledWith({ highlight: { color: '#ffffff', alpha: 1, padX: 6.35, padY: 1.52, radius: 0 } });
});
it('edits an outline that is on, and takes hollow letters back to colour with it', () => {
const outline = { color: '#c2410c', width: 0.5 };
const { set } = mount(text({ outline, fillPaint: { type: 'solid', color: '#1a1a1a', alpha: 0 } }));
const width = num('journey.studio.strokeWidth');
fireEvent.change(width, { target: { value: '1.5' } });
fireEvent.blur(width);
expect(set).toHaveBeenCalledWith({ outline: { ...outline, width: 1.5 } });
fireEvent.click(toggle('journey.studio.stroke'));
expect(set).toHaveBeenCalledWith({ outline: null, fillPaint: null });
});
it('edits a shadow and a band by their numbers', () => {
const shadow = { dx: 1, dy: 1, color: '#111111', alpha: 1 };
const highlight = { color: '#111111', alpha: 1, padX: 1, padY: 0.3, radius: 0 };
const { set } = mount(text({ shadow, highlight }));
const across = num('journey.studio.fx.across');
fireEvent.change(across, { target: { value: '3' } });
fireEvent.blur(across);
expect(set).toHaveBeenCalledWith({ shadow: { ...shadow, dx: 3 } });
const opacity = num('journey.studio.opacity');
fireEvent.change(opacity, { target: { value: '40' } });
fireEvent.blur(opacity);
expect(set).toHaveBeenCalledWith({ shadow: { ...shadow, alpha: 0.4 } });
const pad = num('journey.studio.fx.padDown');
fireEvent.change(pad, { target: { value: '2' } });
fireEvent.blur(pad);
expect(set).toHaveBeenCalledWith({ highlight: { ...highlight, padY: 2 } });
});
it('bends the words on an arc or a circle and straightens them again', () => {
const arc = mount(text({ outline: { color: '#111111', width: 0.2 } }));
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.arc' }));
expect(arc.set).toHaveBeenCalledWith({ curve: { type: 'arc', bend: 40, start: 0 } });
arc.unmount();
const curve = { type: 'circle' as const, bend: 40, start: 0 };
const circle = mount(text({ curve }));
const start = num('journey.studio.fx.start');
fireEvent.change(start, { target: { value: '90' } });
fireEvent.blur(start);
expect(circle.set).toHaveBeenCalledWith({ curve: { ...curve, start: 90 } });
expect(screen.getByText('journey.studio.fx.drawnHint')).toBeTruthy();
expect(screen.getByText('journey.studio.fx.bandDrawn')).toBeTruthy();
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.straight' }));
expect(circle.set).toHaveBeenCalledWith({ curve: null });
});
});
describe('the fill of the letters', () => {
it('leaves hollow letters with an outline in their own colour', () => {
const { set } = mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' }, color: '#0f766e' }));
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.hollow' }));
expect(set).toHaveBeenCalledWith({
fillPaint: { type: 'solid', color: '#0f766e', alpha: 0 },
outline: { color: '#0f766e', width: 1.27 },
});
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.colour' }));
expect(set).toHaveBeenCalledWith({ fillPaint: null });
});
it('fills the letters with a photo of the page first, then lets another be picked, framed and zoomed', () => {
const first = mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' } }));
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.kind.photo' }));
expect(first.set).toHaveBeenCalledWith({
fillPaint: { type: 'photo', photoId: 4, focalX: 0.5, focalY: 0.5, zoom: 1 },
});
first.unmount();
const fill = { type: 'photo' as const, photoId: 4, focalX: 0.5, focalY: 0.5, zoom: 1 };
const { set } = mount(text({ fillPaint: fill }));
const photos = screen.getAllByRole('radio', { name: 'journey.studio.fx.pickPhoto' });
expect(photos).toHaveLength(2);
expect(photos[0]?.getAttribute('aria-checked')).toBe('true');
fireEvent.click(photos[1] as HTMLElement);
expect(set).toHaveBeenCalledWith({ fillPaint: { ...fill, photoId: 5 } });
const zoom = num('journey.studio.fx.zoom');
fireEvent.change(zoom, { target: { value: '150' } });
fireEvent.blur(zoom);
expect(set).toHaveBeenCalledWith({ fillPaint: { ...fill, zoom: 1.5 } });
expect(screen.getByText('journey.studio.fx.focalHint')).toBeTruthy();
});
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();
expect((screen.getByRole('radio', { name: 'journey.studio.kind.photo' }) as HTMLButtonElement).disabled).toBe(true);
});
});
@@ -0,0 +1,369 @@
import {
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
AlignVerticalJustifyStart,
CaseLower,
CaseSensitive,
CaseUpper,
} from 'lucide-react';
import type { BookElement, BookSpread, BookTextElement } from '@trek/shared';
import { bookTextIsDrawn } from '@trek/shared';
import '../../styles/studio-text.css';
import { photoSrc } from './bookRender';
import { Choice, Line, NumField, Section, Switch } from './StudioControls';
import { FocalPad } from './StudioInspector';
import { Swatches } from './StudioSwatches';
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
/**
* The text inspector's effects: how the letters are cased and where the lines
* sit, whether the text shrinks to its frame, and the outline, hard shadow,
* marker band, curve and fill (book-text-effects.schema.ts in shared).
*
* Every effect is a switch that arrives already looking right: an outline in
* the colour that stands out from the letters at a twentieth of their height,
* a shadow a twelfth of it down and across, a band with a quarter em of room.
* A first press that shows something good is the difference between a feature
* people use and a row of switches nobody turns on twice.
*/
type Patch = Partial<BookTextElement>;
type SetPatch = (patch: Partial<BookElement>) => void;
interface Props {
el: BookTextElement;
/** One undo step. */
set: SetPatch;
/** A change inside a drag, as the focal pad makes. */
live: SetPatch;
/** The journey's photos, for filling the letters with one. */
photos: readonly { photoId: number }[];
spread: BookSpread | undefined;
t: (k: string) => string;
}
/** How many photos the fill offers at once: two rows of the panel's thumbnails. */
const FILL_PHOTOS = 12;
function CaseAndPlace({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
return (
<Section label={t('journey.studio.fx.placement')}>
<Line label={t('journey.studio.fx.case')}>
<Choice
value={el.transform ?? 'none'}
ariaLabel={t('journey.studio.fx.case')}
options={[
{ value: 'none' as const, label: <CaseSensitive size={15} />, title: t('journey.studio.fx.caseNone') },
{ value: 'upper' as const, label: <CaseUpper size={15} />, title: t('journey.studio.fx.caseUpper') },
{ value: 'lower' as const, label: <CaseLower size={15} />, title: t('journey.studio.fx.caseLower') },
{ value: 'smallcaps' as const, label: t('journey.studio.fx.caseSmall') },
]}
onPick={(transform) => set({ transform } as Patch)}
/>
</Line>
<Line label={t('journey.studio.fx.vertical')}>
<Choice
value={el.vAlign ?? 'top'}
ariaLabel={t('journey.studio.fx.vertical')}
options={[
{
value: 'top' as const,
label: <AlignVerticalJustifyStart size={15} />,
title: t('journey.studio.fx.top'),
},
{
value: 'middle' as const,
label: <AlignVerticalJustifyCenter size={15} />,
title: t('journey.studio.fx.middle'),
},
{
value: 'bottom' as const,
label: <AlignVerticalJustifyEnd size={15} />,
title: t('journey.studio.fx.bottom'),
},
]}
onPick={(vAlign) => set({ vAlign } as Patch)}
/>
</Line>
<Switch
label={t('journey.studio.fx.fit')}
on={el.fit === 'shrink'}
onToggle={() =>
set((el.fit === 'shrink' ? { fit: 'none', size: el.fitMax ?? el.size } : { fit: 'shrink' }) as Patch)
}
/>
{el.fit === 'shrink' && <p className="st-hint st-fx-hint">{t('journey.studio.fx.fitHint')}</p>}
</Section>
);
}
function OutlineControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
const outline = el.outline;
return (
<div className="st-fx-group">
<Switch
label={t('journey.studio.stroke')}
on={!!outline}
onToggle={() =>
set({
outline: outline ? null : defaultOutline(el),
...(outline && isHollow(el) ? { fillPaint: null } : {}),
} as Patch)
}
/>
{outline && (
<div className="st-fx-body">
<NumField
label={t('journey.studio.strokeWidth')}
value={outline.width}
min={0.05}
max={6}
step={0.05}
unit="mm"
onChange={(width) => set({ outline: { ...outline, width } } as Patch)}
/>
<Swatches value={outline.color} onPick={(color) => set({ outline: { ...outline, color } } as Patch)} />
</div>
)}
</div>
);
}
function ShadowControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
const shadow = el.shadow;
return (
<div className="st-fx-group">
<Switch
label={t('journey.studio.fx.shadow')}
on={!!shadow}
onToggle={() => set({ shadow: shadow ? null : defaultShadow(el) } as Patch)}
/>
{shadow && (
<div className="st-fx-body">
<div className="st-grid2">
<NumField
label={t('journey.studio.fx.across')}
value={shadow.dx}
min={-30}
max={30}
step={0.1}
unit="mm"
onChange={(dx) => set({ shadow: { ...shadow, dx } } as Patch)}
/>
<NumField
label={t('journey.studio.fx.down')}
value={shadow.dy}
min={-30}
max={30}
step={0.1}
unit="mm"
onChange={(dy) => set({ shadow: { ...shadow, dy } } as Patch)}
/>
</div>
<NumField
label={t('journey.studio.opacity')}
value={Math.round(shadow.alpha * 100)}
min={0}
max={100}
step={5}
unit="%"
onChange={(v) => set({ shadow: { ...shadow, alpha: v / 100 } } as Patch)}
/>
<Swatches value={shadow.color} onPick={(color) => set({ shadow: { ...shadow, color } } as Patch)} />
</div>
)}
</div>
);
}
function BandControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
const band = el.highlight;
if (bookTextIsDrawn(el)) {
return <p className="st-hint st-fx-hint">{t('journey.studio.fx.bandDrawn')}</p>;
}
return (
<div className="st-fx-group">
<Switch
label={t('journey.studio.fx.band')}
on={!!band}
onToggle={() => set({ highlight: band ? null : defaultBand(el) } as Patch)}
/>
{band && (
<div className="st-fx-body">
<div className="st-grid2">
<NumField
label={t('journey.studio.fx.padAcross')}
value={band.padX}
min={0}
max={20}
step={0.1}
unit="mm"
onChange={(padX) => set({ highlight: { ...band, padX } } as Patch)}
/>
<NumField
label={t('journey.studio.fx.padDown')}
value={band.padY}
min={0}
max={20}
step={0.1}
unit="mm"
onChange={(padY) => set({ highlight: { ...band, padY } } as Patch)}
/>
</div>
<NumField
label={t('journey.studio.radius')}
value={band.radius}
min={0}
max={20}
step={0.1}
unit="mm"
onChange={(radius) => set({ highlight: { ...band, radius } } as Patch)}
/>
<Swatches value={band.color} onPick={(color) => set({ highlight: { ...band, color } } as Patch)} />
</div>
)}
</div>
);
}
function CurveControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
const curve = el.curve;
return (
<div className="st-fx-group">
<Line label={t('journey.studio.fx.curve')}>
<Choice
value={curve?.type ?? 'none'}
ariaLabel={t('journey.studio.fx.curve')}
options={[
{ value: 'none' as const, label: t('journey.studio.fx.straight') },
{ value: 'arc' as const, label: t('journey.studio.fx.arc') },
{ value: 'circle' as const, label: t('journey.studio.fx.circle') },
]}
onPick={(type) =>
set({
curve: type === 'none' ? null : { type, bend: curve?.bend ?? 40, start: curve?.start ?? 0 },
} as Patch)
}
/>
</Line>
{curve?.type === 'arc' && (
<NumField
label={t('journey.studio.fx.bend')}
value={curve.bend}
min={-100}
max={100}
step={5}
onChange={(bend) => set({ curve: { ...curve, bend } } as Patch)}
/>
)}
{curve?.type === 'circle' && (
<NumField
label={t('journey.studio.fx.start')}
value={curve.start}
min={-180}
max={180}
step={5}
unit="°"
onChange={(start) => set({ curve: { ...curve, start } } as Patch)}
/>
)}
</div>
);
}
type FillKind = 'colour' | 'hollow' | 'photo';
function fillKindOf(el: BookTextElement): FillKind {
if (el.fillPaint?.type === 'photo') return 'photo';
return isHollow(el) ? 'hollow' : 'colour';
}
function FillControls({ el, set, live, photos, spread, t }: Props) {
const kind = fillKindOf(el);
const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null;
const pick = (next: FillKind) => {
if (next === 'colour') return set({ fillPaint: null } as Patch);
if (next === 'hollow') {
return set({
fillPaint: { type: 'solid', color: el.color, alpha: 0 },
// Hollow letters are their outline: one appears with them if there was none.
outline: el.outline ?? { ...defaultOutline(el), 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);
};
return (
<div className="st-fx-group">
<Line label={t('journey.studio.fx.letters')}>
<Choice
value={kind}
ariaLabel={t('journey.studio.fx.letters')}
options={[
{ 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 },
]}
onPick={pick}
/>
</Line>
{!photos.length && <p className="st-hint st-fx-hint">{t('journey.studio.fx.noPhotos')}</p>}
{photo && (
<div className="st-fx-body">
<div className="st-fx-photos" role="radiogroup" aria-label={t('journey.studio.fx.pickPhoto')}>
{photos.slice(0, FILL_PHOTOS).map((p) => (
<button
type="button"
key={p.photoId}
role="radio"
aria-checked={p.photoId === photo.photoId}
aria-label={t('journey.studio.fx.pickPhoto')}
className={`st-fx-photo ${p.photoId === photo.photoId ? 'is-on' : ''}`}
onClick={() => set({ fillPaint: { ...photo, photoId: p.photoId } } as Patch)}
>
<img src={photoSrc(p.photoId, false)} alt="" draggable={false} />
</button>
))}
</div>
<FocalPad
photoId={photo.photoId}
x={photo.focalX}
y={photo.focalY}
onDrag={(focalX, focalY) => live({ fillPaint: { ...photo, focalX, focalY } } as Patch)}
onCommit={(focalX, focalY) => set({ fillPaint: { ...photo, focalX, focalY } } as Patch)}
hint={t('journey.studio.fx.focalHint')}
/>
<NumField
label={t('journey.studio.fx.zoom')}
value={Math.round(photo.zoom * 100)}
min={100}
max={400}
step={10}
unit="%"
onChange={(v) => set({ fillPaint: { ...photo, zoom: v / 100 } } as Patch)}
/>
</div>
)}
</div>
);
}
export function StudioTextEffects(props: Props) {
const { el, set, t } = props;
const any = !!(el.outline || el.shadow || el.highlight || el.curve || el.fillPaint);
return (
<>
<CaseAndPlace el={el} set={set} t={t} />
<Section label={t('journey.studio.fx.effects')} defaultOpen={any}>
<OutlineControls el={el} set={set} t={t} />
<ShadowControls el={el} set={set} t={t} />
<BandControls el={el} set={set} t={t} />
<CurveControls el={el} set={set} t={t} />
<FillControls {...props} />
{bookTextIsDrawn(el) && <p className="st-hint st-fx-hint">{t('journey.studio.fx.drawnHint')}</p>}
</Section>
</>
);
}
@@ -0,0 +1,98 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type { BookDocument, BookTextElement } from '@trek/shared';
import { BOOK_LOCKUPS, bookPageSetupSchema, normalizeBookDocument } from '@trek/shared';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { StudioElementsPanel } from './StudioElementsPanel';
import { StudioTextStyles } from './StudioTextStyles';
/**
* The Text styles shelf: every lockup drawn with the book's own title, placed
* on the open page with one click and selected as one set, its texts fitted.
*/
const t = (k: string) => k;
const page = bookPageSetupSchema.parse({});
const source = { entries: [{ date: '2025-08-12', lat: null, lng: null }], photos: [{ photoId: 3 }] };
const book = (): BookDocument =>
normalizeBookDocument({
version: 1,
title: 'Porto',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads: [{ id: 'sp1', role: 'inner', entryId: null, background: null, elements: [], parked: [] }],
});
/* jsdom lays nothing out; the tiles measure themselves to scale the page drawing into them. */
const size = Object.getOwnPropertyDescriptors(HTMLElement.prototype);
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 120 });
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 90 });
});
afterAll(() => {
for (const key of ['offsetWidth', 'offsetHeight'] as const) {
const was = size[key];
if (was) Object.defineProperty(HTMLElement.prototype, key, was);
}
});
beforeEach(() => {
useStudioStore.getState().load(book());
});
const elements = () => useStudioStore.getState().doc?.spreads[0]?.elements ?? [];
describe('the Text styles shelf', () => {
it('draws every lockup in its group, named, with the book title in it', () => {
render(<StudioTextStyles page={page} source={source} t={t} />);
for (const group of ['titles', 'badges', 'chapters', 'quotes']) {
expect(screen.getByText(`journey.studio.lockupGroup.${group}`)).toBeTruthy();
}
for (const l of BOOK_LOCKUPS)
expect(screen.getByRole('button', { name: `journey.studio.lockup.${l.id}` })).toBeTruthy();
const poster = screen.getByRole('button', { name: 'journey.studio.lockup.poster-stack' });
expect(poster.textContent).toContain('Porto');
expect(poster.textContent).toContain('2025');
expect(screen.getByText('journey.studio.textStylesHint')).toBeTruthy();
});
it('places a lockup on the open page in one undo step, selected as one set, every text fitted', () => {
render(<StudioTextStyles page={page} source={source} t={t} />);
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.lockup.circle-badge' }));
const placed = elements();
expect(placed.length).toBeGreaterThan(3);
expect(useStudioStore.getState().selection).toEqual(placed.map((e) => e.id));
const texts = placed.filter((e): e is BookTextElement => e.kind === 'text');
expect(texts.map((e) => e.text)).toContain('Porto');
expect(texts.every((e) => e.fit === 'shrink')).toBe(true);
for (const e of placed) expect(e.frame.x).toBeGreaterThan(210);
expect(useStudioStore.getState().canUndo()).toBe(true);
useStudioStore.getState().undo();
expect(elements()).toEqual([]);
});
it('opens the Elements panel on the type tab, the plain styles above the lockups', () => {
render(<StudioElementsPanel page={page} t={t} source={source} />);
expect(screen.getByRole('button', { name: 'journey.studio.styleTitle' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.shapes' }));
expect(screen.queryByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeNull();
});
});
describe('a fitted text in the Studio', () => {
it('is solved again whenever the document changes, and grows back when its frame does', () => {
render(<StudioTextStyles page={page} source={source} t={t} />);
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.lockup.minimal-caps' }));
const title = elements()[0] as BookTextElement;
const chosen = title.fitMax ?? title.size;
const store = useStudioStore.getState();
store.setFrame(0, title.id, { ...title.frame, w: 8 });
const squeezed = elements()[0] as BookTextElement;
expect(squeezed.size).toBeLessThan(chosen);
store.setFrame(0, title.id, title.frame);
expect((elements()[0] as BookTextElement).size).toBe(title.size);
});
});
@@ -0,0 +1,131 @@
import { useMemo } from 'react';
import type { BookLockup, BookLockupWords, BookPageSetup } from '@trek/shared';
import { addBookLockup, bookElementId } from '@trek/shared';
import { useElementSize } from '../../hooks/useElementSize';
import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-text.css';
import { ElementView } from './SpreadView';
import { firstFillPhoto } from './textEffects';
import {
SHELF_GROUPS,
type ShelfSource,
placeLockup,
previewLockup,
shelfLockups,
shelfWords,
} from './textStylesModel';
/**
* The Text styles shelf: the 24 lockups, each drawn with the book's own title
* the way it will print, placed with one click and selected together so the
* set moves as one.
*
* The tiles are the page renderer at a small scale, as the travel previews are,
* so a tile can never promise a look the page does not draw.
*/
/** CSS defines 1in as 96px and 25.4mm, so this factor is exact. */
const PX_PER_MM = 96 / 25.4;
function LockupTile({
lockup,
words,
photoId,
label,
onPick,
}: {
lockup: BookLockup;
words: BookLockupWords;
photoId: number | null;
label: string;
onPick: (lockup: BookLockup) => void;
}) {
const { ref, width, height } = useElementSize<HTMLButtonElement>();
const elements = useMemo(() => previewLockup(lockup, words, photoId), [lockup, words, photoId]);
const drawnW = 100 * PX_PER_MM;
const drawnH = lockup.height * PX_PER_MM;
const scale = width > 0 ? Math.min((width * 0.84) / drawnW, (height * 0.78) / drawnH) : 0;
const dx = (width - drawnW * scale) / 2;
const dy = (height - drawnH * scale) / 2;
return (
<button
type="button"
ref={ref}
className="st-lockup"
title={label}
aria-label={label}
onClick={() => onPick(lockup)}
>
{scale > 0 && (
<span
dir="ltr"
aria-hidden
className="st-lockup-art"
style={{
width: '100mm',
height: `${lockup.height}mm`,
transform: `translate(${dx}px, ${dy}px) scale(${scale})`,
}}
>
{elements.map((el) => (
<ElementView key={el.id} el={el} big={false} />
))}
</span>
)}
</button>
);
}
export function StudioTextStyles({
page,
source,
t,
}: {
page: BookPageSetup;
source: ShelfSource;
t: (k: string) => string;
}) {
const title = useStudioStore((s) => s.doc?.title ?? '');
const active = useStudioStore((s) => s.activeSpread);
const spread = useStudioStore((s) => s.doc?.spreads[s.activeSpread]);
const commit = useStudioStore((s) => s.commit);
const select = useStudioStore((s) => s.select);
const words = useMemo(() => shelfWords(title, source, t), [title, source, t]);
const photoId = firstFillPhoto(spread, source.photos);
const place = (lockup: BookLockup) => {
const els = placeLockup(lockup, { page, spread, words, gallery: source.photos, newId: bookElementId });
if (!els.length) return;
commit((d) => ({
...d,
spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)),
}));
// One group, selected, so the set moves, turns and resizes as one straight away.
select(els.map((e) => e.id));
};
return (
<>
{SHELF_GROUPS.map((group) => (
<div className="st-section" key={group.id}>
<div className="st-section-label">{t(`journey.studio.lockupGroup.${group.id}`)}</div>
<div className="st-lockups">
{shelfLockups(group.lockups).map((lockup) => (
<LockupTile
key={lockup.id}
lockup={lockup}
words={words}
photoId={photoId}
label={t(`journey.studio.lockup.${lockup.id}`)}
onPick={place}
/>
))}
</div>
</div>
))}
<p className="st-hint st-fx-hint">{t('journey.studio.textStylesHint')}</p>
</>
);
}
@@ -0,0 +1,136 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { BookTextElement } from '@trek/shared';
import { bookTextElementSchema } from '@trek/shared';
import { render } from '../../../tests/helpers/render';
import { ElementView } from './SpreadView';
import { TextEffectsView } from './TextEffectsView';
/**
* Text effects on the page: every effect a vector layer, never a blur or a
* filter, a curve and a photo fill drawn as SVG with ids of their own, and a
* plain text left to the renderer it always had.
*/
const text = (over: Record<string, unknown> = {}): BookTextElement =>
bookTextElementSchema.parse({
id: 't1',
kind: 'text',
frame: { x: 10, y: 20, w: 100, h: 40 },
text: 'Lisbon',
size: 36,
...over,
});
const frameStyle = { position: 'absolute' as const, width: '100mm', height: '40mm' };
describe('a text that still wraps', () => {
it('stacks a shadow, a band, an outline and the fill, laid out alike', () => {
const el = text({
outline: { color: '#ffffff', width: 0.5 },
shadow: { dx: 1, dy: 2, color: '#111111', alpha: 0.5 },
highlight: { color: '#c2410c', padX: 2, padY: 0.5, radius: 1 },
transform: 'upper',
vAlign: 'bottom',
});
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
const frame = container.firstElementChild as HTMLElement;
const layers = Array.from(frame.children) as HTMLElement[];
expect(layers).toHaveLength(4);
expect(layers.map((l) => l.getAttribute('aria-hidden'))).toEqual(['true', 'true', 'true', null]);
for (const layer of layers) expect(layer.textContent).toBe('Lisbon');
// The styles as React writes them, which is what the print renderer reads.
const html = renderToStaticMarkup(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
expect(html).toContain('overflow:visible');
expect(html).toContain('transform:translate(1mm, 2mm)');
expect(html).toContain('color:rgba(17, 17, 17, 0.5)');
expect(html).toContain('-webkit-text-stroke:1mm rgba(17, 17, 17, 0.5)');
expect(html).toContain('background:#c2410c');
expect(html).toContain('box-decoration-break:clone');
expect(html).toContain('-webkit-text-stroke:1mm #ffffff');
expect(html.match(/text-transform:uppercase/g)).toHaveLength(4);
expect(html.match(/justify-content:flex-end/g)).toHaveLength(4);
expect(html).not.toMatch(/filter|blur|background-clip/);
});
it('draws hollow letters as their outline alone', () => {
const el = text({
outline: { color: '#111111', width: 0.4 },
fillPaint: { type: 'solid', color: '#111111', alpha: 0 },
});
const html = renderToStaticMarkup(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
expect(html).toContain('color:transparent;-webkit-text-stroke:0.4mm #111111');
expect(html).toContain('color:rgba(17, 17, 17, 0)');
});
it('keeps a text with only a case or a vertical place clipped to its frame, as plain text is', () => {
const el = text({ transform: 'smallcaps' });
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
expect((container.firstElementChild as HTMLElement).children).toHaveLength(1);
const html = renderToStaticMarkup(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
expect(html).toContain('overflow:hidden');
expect(html).toContain('font-variant-caps:small-caps');
});
});
describe('a text drawn as SVG', () => {
it('lays a curved text on its path with textPath, the path in the millimetres of the frame', () => {
const el = text({
curve: { type: 'arc', bend: 40, start: 0 },
align: 'center',
shadow: { dx: 1, dy: 1, color: '#111111' },
});
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
const svg = container.querySelector('svg');
expect(svg?.getAttribute('viewBox')).toBe('0 0 100 40');
const path = svg?.querySelector('defs path');
const used = svg?.getElementsByTagName('textPath') ?? [];
expect(used).toHaveLength(2);
for (const tp of Array.from(used)) {
expect(tp.getAttribute('href')).toBe(`#${path?.getAttribute('id')}`);
expect(tp.getAttribute('startOffset')).toBe('50%');
expect(tp.textContent).toBe('Lisbon');
}
expect(svg?.querySelector('g')?.getAttribute('transform')).toBe('translate(1 1)');
});
it('fills the letters with the photo through a clip of the letters, waiting on an <image>', () => {
const el = text({ text: 'LIS\nBON', fillPaint: { type: 'photo', photoId: 12, focalX: 0.5, focalY: 0.5, zoom: 1 } });
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big />);
const image = container.querySelector('image');
expect(image?.getAttribute('href')).toBe('/api/photos/12/original');
const clip = container.getElementsByTagName('clipPath')[0];
expect(image?.getAttribute('clip-path')).toBe(`url(#${clip?.getAttribute('id')})`);
expect(clip?.getAttribute('clipPathUnits')).toBe('userSpaceOnUse');
expect(Array.from(clip?.querySelectorAll('tspan') ?? []).map((t) => t.textContent)).toEqual(['LIS', 'BON']);
});
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(
<>
<TextEffectsView el={el} frameStyle={frameStyle} big={false} />
<TextEffectsView el={el} frameStyle={frameStyle} big={false} />
</>
);
const ids = Array.from(container.querySelectorAll('[id]')).map((n) => n.id);
expect(ids).toHaveLength(4);
expect(new Set(ids).size).toBe(4);
});
});
describe('the page renderer', () => {
it('draws a text with effects through the effect view and a plain one as it always did', () => {
const { container: fx } = render(
<ElementView el={text({ outline: { color: '#111111', width: 0.3 } })} big={false} />
);
expect((fx.firstElementChild as HTMLElement).children).toHaveLength(2);
const { container: plain } = render(<ElementView el={text()} big={false} />);
const div = plain.firstElementChild as HTMLElement;
expect(div.textContent).toBe('Lisbon');
expect(div.children).toHaveLength(0);
});
});
@@ -0,0 +1,254 @@
import type { CSSProperties, ReactNode } from 'react';
import { useId } from 'react';
import type { BookTextElement } from '@trek/shared';
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn } from '@trek/shared';
import { fontStack } from './bookFonts';
import { photoSrc } from './bookRender';
import {
caseStyle,
curveAnchor,
isHollow,
letterFill,
lineAnchor,
lineCentres,
outlineStrokeMm,
photoCover,
ptToMm,
svgId,
withAlpha,
} from './textEffects';
/**
* A text with effects, drawn.
*
* ── Two ways of drawing, both vector in print ──────────────────────────────
*
* A text that still wraps (an outline, a shadow, a band, a case, a vertical
* place) stays HTML type, drawn as a stack of identical layers: the shadow, a
* copy offset behind; the band, a span background behind the words; the
* outline, a copy stroked twice its width under the fill, so the line grows
* outward and never thins the letter; then the fill. Every layer lays its
* words out the same way, so they land on each other exactly, and none of them
* is a blur, a filter or a `background-clip`, which print as pixels or wait
* for nothing.
*
* A text on a curve or filled with a photograph is SVG: the curve is
* `textPath` on a path computed in millimetres (book-text-curve.ts in shared),
* the photo an `<image>` clipped to the letters, which the print view waits
* for like any other picture. Such a text is set line by line as written.
*
* Every id is this instance's own: a page drawn twice on one sheet (page mode)
* must not have its second copy clip to the first copy's letters.
*/
const JUSTIFY = { top: 'flex-start', middle: 'center', bottom: 'flex-end' } as const;
/** The type itself, the same on every layer of an HTML text. */
function typeStyle(el: BookTextElement): CSSProperties {
return {
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
justifyContent: JUSTIFY[el.vAlign ?? 'top'],
fontSize: `${el.size}pt`,
fontFamily: fontStack(el.font),
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
textAlign: el.align,
whiteSpace: 'pre-wrap',
hyphens: 'auto',
...caseStyle(el),
};
}
/** The words of one layer, padded as the band pads them so every layer breaks its lines alike. */
function Words({ el, band }: { el: BookTextElement; band?: string }) {
const hl = el.highlight;
const style: CSSProperties | undefined = hl
? {
padding: `${hl.padY}mm ${hl.padX}mm`,
borderRadius: hl.radius ? `${hl.radius}mm` : undefined,
background: band,
boxDecorationBreak: 'clone',
WebkitBoxDecorationBreak: 'clone',
}
: undefined;
return (
<div>
<span style={style}>{el.text}</span>
</div>
);
}
function LayeredText({ el, frameStyle }: { el: BookTextElement; frameStyle: CSSProperties }) {
const type = typeStyle(el);
const { outline, shadow, highlight } = el;
const hollow = isHollow(el);
const stroke = outline ? `${outlineStrokeMm(el)}mm` : undefined;
const reaches = !!(outline || shadow || highlight);
const layers: ReactNode[] = [];
if (shadow) {
const ink = withAlpha(shadow.color, shadow.alpha);
layers.push(
<div
key="shadow"
aria-hidden
style={{
...type,
transform: `translate(${shadow.dx}mm, ${shadow.dy}mm)`,
color: hollow ? 'transparent' : ink,
WebkitTextStroke: stroke ? `${stroke} ${ink}` : undefined,
}}
>
<Words el={el} band={highlight ? ink : undefined} />
</div>
);
}
if (highlight) {
layers.push(
<div key="band" aria-hidden style={{ ...type, color: 'transparent' }}>
<Words el={el} band={withAlpha(highlight.color, highlight.alpha)} />
</div>
);
}
if (outline) {
layers.push(
<div
key="outline"
aria-hidden
style={{
...type,
color: hollow ? 'transparent' : outline.color,
WebkitTextStroke: `${stroke} ${outline.color}`,
}}
>
<Words el={el} />
</div>
);
}
layers.push(
<div key="fill" style={{ ...type, color: letterFill(el) ?? el.color }}>
<Words el={el} />
</div>
);
return <div style={{ ...frameStyle, overflow: reaches ? 'visible' : 'hidden' }}>{layers}</div>;
}
interface GlyphPaint {
fill: string;
stroke?: string;
strokeWidth?: number;
}
function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: CSSProperties; big: boolean }) {
const reactId = useId();
const pathId = svgId('tp-', reactId);
const clipId = svgId('tc-', 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;
const hollow = isHollow(el);
const { outline, shadow } = el;
const textProps = {
fontFamily: fontStack(el.font),
fontSize: fontMm,
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
style: { letterSpacing: `${el.tracking}em`, fontVariantCaps: caseStyle(el).fontVariantCaps },
};
const glyphs = (paint: GlyphPaint | null, key: string) => {
const painted = paint ? { ...paint, strokeLinejoin: 'round' as const } : {};
if (curve) {
const at = curveAnchor(el.align);
return (
<text key={key} {...textProps} {...painted}>
<textPath href={`#${pathId}`} startOffset={at.offset} textAnchor={at.anchor}>
{lines[0]}
</textPath>
</text>
);
}
const at = lineAnchor(el.align, w);
const ys = lineCentres(lines.length, fontMm, el.leading, h, el.vAlign);
return (
<text key={key} {...textProps} {...painted} textAnchor={at.anchor} dominantBaseline="central">
{lines.map((line, i) => (
<tspan key={i} x={at.x} y={ys[i]}>
{line || ' '}
</tspan>
))}
</text>
);
};
const stroke = outlineStrokeMm(el);
const layers: ReactNode[] = [];
if (shadow) {
const ink = withAlpha(shadow.color, shadow.alpha);
layers.push(
<g key="shadow" transform={`translate(${shadow.dx} ${shadow.dy})`} aria-hidden>
{glyphs(
{ fill: hollow ? 'none' : ink, stroke: stroke ? ink : undefined, strokeWidth: stroke || undefined },
'g'
)}
</g>
);
}
if (outline) {
layers.push(
glyphs({ fill: hollow ? 'none' : outline.color, stroke: outline.color, strokeWidth: stroke }, 'outline')
);
}
if (photo) {
const cover = photoCover(w, h, photo);
layers.push(<image key="photo" href={photoSrc(photo.photoId, big)} {...cover} clipPath={`url(#${clipId})`} />);
} else {
layers.push(glyphs({ fill: letterFill(el) ?? el.color }, 'fill'));
}
return (
<div style={frameStyle}>
<svg
width={`${w}mm`}
height={`${h}mm`}
viewBox={`0 0 ${w} ${h}`}
style={{ display: 'block', overflow: 'visible' }}
role="img"
aria-label={el.text}
>
<defs>
{curve && <path id={pathId} d={curve.d} fill="none" />}
{photo && (
<clipPath id={clipId} clipPathUnits="userSpaceOnUse">
{glyphs(null, 'clip')}
</clipPath>
)}
</defs>
{layers}
</svg>
</div>
);
}
/** A text element that has effects; SpreadView keeps drawing the plain ones itself. */
export function TextEffectsView({
el,
frameStyle,
big,
}: {
el: BookTextElement;
frameStyle: CSSProperties;
big: boolean;
}) {
if (bookTextIsDrawn(el)) return <DrawnText el={el} frameStyle={frameStyle} big={big} />;
return <LayeredText el={el} frameStyle={frameStyle} />;
}
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import type { BookSpread, BookTextElement } from '@trek/shared';
import { bookTextElementSchema } from '@trek/shared';
import {
caseStyle,
curveAnchor,
defaultBand,
defaultOutline,
defaultShadow,
firstFillPhoto,
hasTextEffects,
isHollow,
letterFill,
lineAnchor,
lineCentres,
outlineStrokeMm,
photoCover,
ptToMm,
svgId,
withAlpha,
} from './textEffects';
const text = (over: Record<string, unknown> = {}): BookTextElement =>
bookTextElementSchema.parse({
id: 't1',
kind: 'text',
frame: { x: 0, y: 0, w: 100, h: 40 },
text: 'Lisbon',
size: 36,
...over,
});
describe('which texts take the effect path', () => {
it('leaves a plain text, and one set to the plain values, to the plain renderer', () => {
expect(hasTextEffects(text())).toBe(false);
expect(hasTextEffects(text({ transform: 'none', vAlign: 'top', outline: null, fit: 'shrink' }))).toBe(false);
});
it('takes every effect that draws', () => {
for (const over of [
{ transform: 'upper' },
{ vAlign: 'middle' },
{ outline: { color: '#111111', width: 0.4 } },
{ shadow: { dx: 1, dy: 1, color: '#111111' } },
{ highlight: { color: '#111111' } },
{ curve: { type: 'arc' } },
{ fillPaint: { type: 'solid', color: '#111111', alpha: 0.5 } },
]) {
expect(hasTextEffects(text(over)), JSON.stringify(over)).toBe(true);
}
});
});
describe('colours and strokes', () => {
it('keeps an opaque colour its hex and spells a translucent one out', () => {
expect(withAlpha('#ff8000')).toBe('#ff8000');
expect(withAlpha('#ff8000', 0.5)).toBe('rgba(255, 128, 0, 0.5)');
expect(withAlpha('#ff8000', -1)).toBe('rgba(255, 128, 0, 0)');
});
it('fills the letters with the colour, a solid fill, or nothing under a photo', () => {
expect(letterFill(text({ color: '#222222' }))).toBe('#222222');
expect(letterFill(text({ fillPaint: { type: 'solid', color: '#0f766e', alpha: 0.4 } }))).toBe(
'rgba(15, 118, 110, 0.4)'
);
expect(letterFill(text({ fillPaint: { type: 'photo', photoId: 3 } }))).toBeNull();
});
it('strokes a solid letter twice the outline, a hollow one at it, and nothing without one', () => {
const outline = { color: '#111111', width: 0.5 };
expect(outlineStrokeMm(text({ outline }))).toBe(1);
const hollow = text({ outline, fillPaint: { type: 'solid', color: '#111111', alpha: 0 } });
expect(isHollow(hollow)).toBe(true);
expect(outlineStrokeMm(hollow)).toBe(0.5);
expect(outlineStrokeMm(text())).toBe(0);
});
it('cases the letters in CSS', () => {
expect(caseStyle(text({ transform: 'upper' }))).toEqual({ textTransform: 'uppercase' });
expect(caseStyle(text({ transform: 'lower' }))).toEqual({ textTransform: 'lowercase' });
expect(caseStyle(text({ transform: 'smallcaps' }))).toEqual({ fontVariantCaps: 'small-caps' });
expect(caseStyle(text())).toEqual({});
});
});
describe('where a drawn text sits', () => {
it('anchors a line by the alignment, across the frame and along a curve', () => {
expect(lineAnchor('left', 100)).toEqual({ x: 0, anchor: 'start' });
expect(lineAnchor('center', 100)).toEqual({ x: 50, anchor: 'middle' });
expect(lineAnchor('right', 100)).toEqual({ x: 100, anchor: 'end' });
expect(lineAnchor('justify', 100)).toEqual({ x: 0, anchor: 'start' });
expect(curveAnchor('center')).toEqual({ offset: '50%', anchor: 'middle' });
expect(curveAnchor('right')).toEqual({ offset: '100%', anchor: 'end' });
});
it('stacks the lines at the leading, at the top, middle or foot of the frame', () => {
expect(lineCentres(2, 10, 1, 40, 'top')).toEqual([5, 15]);
expect(lineCentres(2, 10, 1, 40, 'middle')).toEqual([15, 25]);
expect(lineCentres(2, 10, 1, 40, 'bottom')).toEqual([25, 35]);
expect(lineCentres(1, 10, 1.2, 40, undefined)).toEqual([6]);
});
it('covers the letters with the photo, zoomed about the focal point', () => {
expect(photoCover(100, 40, { focalX: 0.5, focalY: 0.5, zoom: 1 })).toEqual({
x: 0,
y: 0,
width: 100,
height: 40,
preserveAspectRatio: 'xMidYMid slice',
});
const zoomed = photoCover(100, 40, { focalX: 0, focalY: 1, zoom: 2 });
expect(zoomed).toEqual({ x: -0, y: -40, width: 200, height: 80, preserveAspectRatio: 'xMinYMax slice' });
});
it('makes an id fit for url(#…) out of React’s', () => {
expect(svgId('tc-', ':r12:')).toBe('tc-r12');
expect(ptToMm(72)).toBeCloseTo(25.4, 6);
});
});
describe('an effect as it is switched on', () => {
it('arrives in a colour that stands out from the letters, sized from them', () => {
const dark = text({ color: '#1a1a1a', size: 72 });
expect(defaultOutline(dark)).toEqual({ color: '#ffffff', width: 1.27 });
expect(defaultShadow(dark)).toEqual({ dx: 2.03, dy: 2.03, color: '#ffffff', alpha: 1 });
expect(defaultBand(dark)).toEqual({ color: '#ffffff', alpha: 1, padX: 6.35, padY: 1.52, radius: 0 });
expect(defaultOutline(text({ color: '#ffffff', size: 4 })).width).toBe(0.1);
});
it('fills with a photo of the page first, then with the first of the journey', () => {
const spread = {
id: 'sp',
role: 'inner',
background: null,
parked: [],
entryId: null,
elements: [{ kind: 'photo', photoId: 9 }],
} as unknown as BookSpread;
expect(firstFillPhoto(spread, [{ photoId: 2 }])).toBe(9);
expect(firstFillPhoto(undefined, [{ photoId: 2 }])).toBe(2);
expect(firstFillPhoto(undefined, [])).toBeNull();
});
});
+162
View File
@@ -0,0 +1,162 @@
import type { BookSpread, BookTextElement } from '@trek/shared';
import { readableInk } from '@trek/shared';
/**
* The arithmetic behind TextEffectsView, kept apart from the component so it
* can be read and tested without a DOM: which texts take the effect path at
* all, the colours with their alpha, where each line of a drawn text sits and
* how a photo covers the letters it fills.
*/
/** Whether a text has anything SpreadView's plain text does not draw. A plain one keeps the plain path. */
export function hasTextEffects(el: BookTextElement): boolean {
return (
(!!el.transform && el.transform !== 'none') ||
(!!el.vAlign && el.vAlign !== 'top') ||
!!el.outline ||
!!el.shadow ||
!!el.highlight ||
!!el.curve ||
!!el.fillPaint
);
}
/** `#rrggbb` with an alpha, as CSS and SVG both take it. Fully opaque stays the hex itself. */
export function withAlpha(hex: string, alpha = 1): string {
if (alpha >= 1) return hex;
const n = Number.parseInt(hex.slice(1), 16);
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. */
export function letterFill(el: BookTextElement): string | null {
const fill = el.fillPaint;
if (!fill) return el.color;
return fill.type === 'solid' ? withAlpha(fill.color, fill.alpha) : null;
}
/** Hollow letters: a solid fill of no alpha, which leaves the outline alone on the page. */
export function isHollow(el: BookTextElement): boolean {
return el.fillPaint?.type === 'solid' && el.fillPaint.alpha === 0;
}
/**
* How wide an outline's stroke is drawn, in millimetres. A stroke is centred
* on the letter's edge and the fill covers its inner half, so a solid letter is
* stroked twice the outline's width; a hollow one shows the whole stroke, so it
* is stroked at the width itself. Zero without an outline.
*/
export function outlineStrokeMm(el: BookTextElement): number {
if (!el.outline) return 0;
return isHollow(el) ? el.outline.width : el.outline.width * 2;
}
/** Points to millimetres, the unit a drawn text's SVG is laid out in. */
export const ptToMm = (pt: number) => (pt * 25.4) / 72;
/** The CSS of a text's case. */
export function caseStyle(el: BookTextElement): {
textTransform?: 'uppercase' | 'lowercase';
fontVariantCaps?: 'small-caps';
} {
if (el.transform === 'upper') return { textTransform: 'uppercase' };
if (el.transform === 'lower') return { textTransform: 'lowercase' };
if (el.transform === 'smallcaps') return { fontVariantCaps: 'small-caps' };
return {};
}
const ANCHOR = { left: 'start', center: 'middle', right: 'end', justify: 'start' } as const;
/** How far across the frame, and along a curve, each anchor sits. */
const ACROSS = { start: 0, middle: 0.5, end: 1 } as const;
/** How far down the frame's spare room the block of lines sits. */
const DOWN = { top: 0, middle: 0.5, bottom: 1 } as const;
/** Where a drawn line starts and how it is anchored, from the text's alignment. */
export function lineAnchor(
align: BookTextElement['align'],
w: number
): { x: number; anchor: 'start' | 'middle' | 'end' } {
const anchor = ANCHOR[align];
return { x: w * ACROSS[anchor], anchor };
}
/** The same anchor along a curve: where on the path the words are pinned. */
export function curveAnchor(align: BookTextElement['align']): { offset: string; anchor: 'start' | 'middle' | 'end' } {
const anchor = ANCHOR[align];
return { offset: `${ACROSS[anchor] * 100}%`, anchor };
}
/**
* The centre line of each line of a drawn text, in millimetres from the
* frame's top: the lines stacked at the text's leading, the block set at the
* top, middle or foot of the frame. Drawn with `dominant-baseline: central`,
* which centres the letters on these lines.
*/
export function lineCentres(
count: number,
fontMm: number,
leading: number,
h: number,
vAlign: BookTextElement['vAlign']
): number[] {
const lineH = fontMm * leading;
const total = lineH * count;
const top = (h - total) * DOWN[vAlign ?? 'top'];
return Array.from({ length: count }, (_, i) => top + lineH * i + lineH / 2);
}
/**
* How a photograph lies under the letters it fills: a box `zoom` times the
* frame, moved so the focal point stays where it is wanted, and the picture
* cropped into that box from the side the focal point is on.
*/
export function photoCover(
w: number,
h: number,
fill: { focalX: number; focalY: number; zoom: number }
): { x: number; y: number; width: number; height: number; preserveAspectRatio: string } {
const width = w * fill.zoom;
const height = h * fill.zoom;
const side = (v: number) => {
if (v < 0.34) return 'Min';
return v > 0.66 ? 'Max' : 'Mid';
};
return {
x: (w - width) * fill.focalX,
y: (h - height) * fill.focalY,
width,
height,
preserveAspectRatio: `x${side(fill.focalX)}Y${side(fill.focalY)} slice`,
};
}
/** An id fit for `url(#…)`, from React's useId, which carries colons. */
export function svgId(prefix: string, reactId: string): string {
return `${prefix}${reactId.replace(/[^A-Za-z0-9_-]/g, '')}`;
}
const r2 = (v: number) => Math.round(v * 100) / 100;
/** An outline as it is first switched on: a contrasting line about a twentieth of the letter height. */
export function defaultOutline(el: BookTextElement): NonNullable<BookTextElement['outline']> {
return { color: readableInk(el.color), width: Math.max(0.1, r2(ptToMm(el.size) * 0.05)) };
}
/** A hard shadow as it is first switched on: offset down and to the right by a twelfth of the letter height. */
export function defaultShadow(el: BookTextElement): NonNullable<BookTextElement['shadow']> {
const off = Math.max(0.2, r2(ptToMm(el.size) * 0.08));
return { dx: off, dy: off, color: readableInk(el.color), alpha: 1 };
}
/** A band as it is first switched on: a contrasting ground with room of a quarter em either side. */
export function defaultBand(el: BookTextElement): NonNullable<BookTextElement['highlight']> {
const em = ptToMm(el.size);
return { color: readableInk(el.color), alpha: 1, padX: r2(em * 0.25), padY: r2(em * 0.06), radius: 0 };
}
/** The photo a photo fill starts from: the first picture on the page, else the first of the journey. */
export function firstFillPhoto(spread: BookSpread | undefined, gallery: readonly { photoId: number }[]): number | null {
const onPage = spread?.elements.find((e) => e.kind === 'photo' && e.photoId != null);
if (onPage?.kind === 'photo' && onPage.photoId != null) return onPage.photoId;
return gallery[0]?.photoId ?? null;
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import type { BookSpread } from '@trek/shared';
import { BOOK_LOCKUPS, bookElementSchema, bookPageSetupSchema, findBookLockup, formatBookCoords } from '@trek/shared';
import { SHELF_GROUPS, placeLockup, previewLockup, shelfLockups, shelfWords } from './textStylesModel';
const t = (k: string) => `[${k}]`;
const page = bookPageSetupSchema.parse({});
const source = {
entries: [
{ date: null, lat: null, lng: null },
{ date: '2025-08-12', lat: 41.1496, lng: -8.611 },
],
photos: [{ photoId: 3 }],
};
const lockup = (id: string) => {
const found = findBookLockup(id);
if (!found) throw new Error(`${id} is missing`);
return found;
};
describe('the words of the shelf', () => {
it("fills a lockup with the book's title, the year and point of its first stop, and samples for the rest", () => {
const words = shelfWords(' Porto ', source, t);
expect(words.title).toBe('Porto');
expect(words.year).toBe('2025');
expect(words.coords).toBe(formatBookCoords(41.1496, -8.611));
expect(words.subtitle).toBe('[journey.studio.lockupWord.subtitle]');
expect(words.number).toBe('01');
});
it('falls back to a sample title, this year and Lisbon for a book that has none of them', () => {
const words = shelfWords('', { entries: [], photos: [] }, t, new Date('2031-03-01T12:00:00'));
expect(words.title).toBe('[journey.studio.lockupWord.title]');
expect(words.year).toBe('2031');
expect(words.coords).toBe(formatBookCoords(38.7139, -9.1334));
});
});
describe('the shelf', () => {
it('shows every lockup of the catalogue once, in its groups', () => {
const shown = SHELF_GROUPS.flatMap((g) => shelfLockups(g.lockups).map((l) => l.id));
expect(shown).toHaveLength(BOOK_LOCKUPS.length);
expect(new Set(shown)).toEqual(new Set(BOOK_LOCKUPS.map((l) => l.id)));
expect(shelfLockups(['nope', 'stamp']).map((l) => l.id)).toEqual(['stamp']);
});
it('places a lockup on the page it is on, filling a photo word from that page', () => {
const spread = {
id: 'sp',
role: 'cover',
background: null,
parked: [],
entryId: null,
elements: [{ id: 'p', kind: 'photo', photoId: 8 }],
} as unknown as BookSpread;
let n = 0;
const els = placeLockup(lockup('photo-word'), {
page,
spread,
words: shelfWords('Porto', source, t),
gallery: source.photos,
newId: (p) => `${p}-${(n += 1)}`,
});
expect(els.map((e) => e.id)).toEqual(['t-1', 't-2']);
for (const el of els) expect(el.frame.x + el.frame.w).toBeLessThanOrEqual(page.pageWidth);
const word = els[1];
expect(word?.kind === 'text' && word.fillPaint).toMatchObject({ type: 'photo', photoId: 8 });
});
it('draws a preview at the origin, 100 mm wide, every element one the document reads', () => {
const els = previewLockup(lockup('postmark'), shelfWords('Porto', source, t), null);
expect(els.length).toBeGreaterThan(3);
expect(new Set(els.map((e) => e.id)).size).toBe(els.length);
for (const el of els) {
expect(bookElementSchema.safeParse(el).success).toBe(true);
expect(el.frame.x).toBeGreaterThanOrEqual(0);
expect(el.frame.x + el.frame.w).toBeLessThanOrEqual(100.01);
}
});
});
@@ -0,0 +1,103 @@
import type { BookElement, BookLockup, BookLockupWords, BookPageSetup, BookSpread } from '@trek/shared';
import { BOOK_LOCKUPS, bookLockupBox, buildBookLockup, formatBookCoords } from '@trek/shared';
import { firstFillPhoto } from './textEffects';
/**
* What the Text styles shelf places and draws: the words a lockup is filled
* with, the order the shelf shows the lockups in, and the elements one lockup
* becomes on a page.
*/
/** What the shelf knows of the journey: its stops, for a year and a point. */
export interface ShelfSource {
entries: readonly { date: string | null; lat: number | null; lng: number | null }[];
photos: readonly { photoId: number }[];
}
/** Lisbon, where the samples are set, for a journey that has recorded no point. */
const SAMPLE_POINT = { lat: 38.7139, lng: -9.1334 };
/**
* The words every lockup is filled with: the book's own title and the year of
* its first dated stop, its first recorded point, and translated samples for
* the rest, which are there to be overwritten.
*/
export function shelfWords(
title: string,
source: ShelfSource,
t: (k: string) => string,
now: Date = new Date()
): BookLockupWords {
const dated = source.entries.find((e) => e.date)?.date;
const year = dated ? dated.slice(0, 4) : String(now.getFullYear());
const point = source.entries.find((e) => e.lat != null && e.lng != null);
return {
title: title.trim() || t('journey.studio.lockupWord.title'),
subtitle: t('journey.studio.lockupWord.subtitle'),
kicker: t('journey.studio.lockupWord.kicker'),
overlay: t('journey.studio.lockupWord.overlay'),
year,
place: t('journey.studio.lockupWord.place'),
dates: t('journey.studio.lockupWord.dates'),
quote: t('journey.studio.lockupWord.quote'),
caption: t('journey.studio.lockupWord.caption'),
number: '01',
label: t('journey.studio.lockupWord.label'),
coords: formatBookCoords(point?.lat ?? SAMPLE_POINT.lat, point?.lng ?? SAMPLE_POINT.lng),
};
}
/** The shelf's groups, in the order a page is usually built: a title first, the small marks last. */
export const SHELF_GROUPS = [
{
id: 'titles',
lockups: [
'poster-stack',
'spaced-date',
'serif-sun',
'title-script-year',
'script-overlay',
'photo-word',
'fabric-band',
'arched-title',
'retro-shadow',
'outline-echo',
'minimal-caps',
],
},
{ id: 'badges', lockups: ['circle-badge', 'postmark', 'stamp', 'ticket'] },
{ id: 'chapters', lockups: ['chapter-outline', 'chapter-rules', 'day-opener'] },
{
id: 'quotes',
lockups: ['quote-classic', 'quote-band', 'caption-rule', 'caption-number', 'coords-pin', 'coords-stack'],
},
] as const;
/** A group's lockups, as the catalogue has them. */
export function shelfLockups(ids: readonly string[]): BookLockup[] {
return ids.flatMap((id) => BOOK_LOCKUPS.filter((l) => l.id === id));
}
/** Where a lockup is placed and with what. */
export interface LockupPlacement {
page: BookPageSetup;
spread: BookSpread | undefined;
words: BookLockupWords;
gallery: readonly { photoId: number }[];
newId: (prefix: string) => string;
}
/** The elements one lockup becomes, centred on the page it is placed on, a photo word filled from that page. */
export function placeLockup(lockup: BookLockup, at: LockupPlacement): BookElement[] {
const single = !!at.spread && at.spread.role !== 'inner';
const photoId = firstFillPhoto(at.spread, at.gallery);
return buildBookLockup(lockup, bookLockupBox(lockup, at.page, single), { words: at.words, photoId, newId: at.newId });
}
/** A lockup drawn in a box of its own proportions, 100 mm wide, for the shelf's preview. */
export function previewLockup(lockup: BookLockup, words: BookLockupWords, photoId: number | null): BookElement[] {
let n = 0;
const box = { x: 0, y: 0, w: 100, h: lockup.height };
return buildBookLockup(lockup, box, { words, photoId, newId: (p) => `${p}-preview-${(n += 1)}` });
}
+4 -3
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand'
import type { BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared'
import {
MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS, applySavedBookLayout as applySavedLayout,
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, innerSpreadBounds as innerBounds,
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, fitBookTexts, innerSpreadBounds as innerBounds,
withThemeLinks,
} from '@trek/shared'
import { pastedElements, type StudioClipboard } from '../components/Studio/studioClipboard'
@@ -195,14 +195,15 @@ export const useStudioStore = create<StudioState>((set, get) => ({
}
}),
// A text set to fit its frame is solved after every change (book-text-fit.ts in shared).
commit: fn => set(s => {
if (!s.doc || s.readOnly) return {}
const next = fn(s.doc)
const next = fitBookTexts(fn(s.doc), s.doc)
if (next === s.doc) return {}
return { doc: next, past: [...s.past, s.doc].slice(-HISTORY_LIMIT), future: [] }
}),
apply: fn => set(s => (s.doc && !s.readOnly ? { doc: fn(s.doc) } : {})),
apply: fn => set(s => (s.doc && !s.readOnly ? { doc: fitBookTexts(fn(s.doc), s.doc) } : {})),
// A colour or face set by hand stops following the theme; a link set here takes the theme's value.
updateElement: (spreadIndex, id, patch) => get().apply(doc =>
+85
View File
@@ -0,0 +1,85 @@
/* Text effects in the inspector and the Text styles shelf (StudioTextEffects.tsx,
StudioTextStyles.tsx). Kept beside studio.css, which is at its size ceiling. */
/* One effect: its switch, then its settings indented under it while it is on. */
.trek-dash .st-fx-group + .st-fx-group {
border-top: 1px solid var(--line);
margin-top: 4px;
padding-top: 4px;
}
.trek-dash .st-fx-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0 10px;
}
.trek-dash .st-fx-hint {
padding: 4px 0 6px;
}
/* The photos the letters can be filled with: the content panel's thumbnails, smaller. */
.trek-dash .st-fx-photos {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 5px;
}
.trek-dash .st-fx-photo {
aspect-ratio: 1;
min-width: 0;
border-radius: 7px;
overflow: hidden;
background: var(--surface-2);
border: 1px solid var(--line);
transition:
box-shadow 0.14s,
transform 0.14s ease-out;
}
.trek-dash .st-fx-photo:hover {
transform: translateY(-1px);
}
.trek-dash .st-fx-photo.is-on {
box-shadow: 0 0 0 2px var(--accent);
}
.trek-dash .st-fx-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* The Text styles shelf: each lockup drawn as it will print, on paper. */
.trek-dash .st-lockups {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.trek-dash .st-lockup {
position: relative;
min-width: 0;
aspect-ratio: 4 / 3;
border-radius: 11px;
overflow: hidden;
/* Paper, not app chrome: the lockup is shown on the page it will be printed on. */
background: white;
border: 1px solid var(--line);
transition:
transform 0.14s ease-out,
box-shadow 0.14s,
border-color 0.16s;
}
.trek-dash .st-lockup:hover {
transform: translateY(-1px);
border-color: var(--line-2);
box-shadow: var(--sh-md);
}
.trek-dash .st-lockup:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.trek-dash .st-lockup-art {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
transform-origin: 0 0;
pointer-events: none;
}