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
@@ -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>
</>
);
}