mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)}` });
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user