mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
523 lines
19 KiB
TypeScript
523 lines
19 KiB
TypeScript
import { bookElementId } from './book-ids';
|
|
import type { BookElement, BookPageSetup, BookSpread } from './book.schema';
|
|
|
|
/**
|
|
* Layout templates for a spread.
|
|
*
|
|
* Moved from the Studio (client/src/components/Studio/templates.ts) so the
|
|
* editor, the assistant's book tools and a chat card mean the same thing by
|
|
* "choose a layout". The arrangements and the applier are unchanged; the
|
|
* parity spec beside this file pins that.
|
|
*
|
|
* A template is not a special mode and not a locked page: it is a set of frames
|
|
* with the pictures left out. Applying one keeps the photos and the words that
|
|
* are already on the spread and pours them into the new arrangement, in order;
|
|
* anything the new layout has room for but the old one did not fill stays an
|
|
* empty frame you can drop a picture onto.
|
|
*
|
|
* Content the new layout has no room for is **parked, not deleted**. Trying a
|
|
* text-only layout and then changing your mind must not cost you the pictures:
|
|
* they come straight back the moment a layout with frames is applied. Undo would
|
|
* technically get them back too, but "I clicked the wrong card" should not need
|
|
* a rescue.
|
|
*
|
|
* That is the difference between a template system that helps and one that
|
|
* fights you: nothing here is remembered as a mode. Once applied, the spread is
|
|
* an ordinary spread.
|
|
*/
|
|
|
|
export interface BookTemplate {
|
|
id: string;
|
|
labelKey: string;
|
|
/** How many pictures the layout has room for. */
|
|
photoSlots: number;
|
|
/**
|
|
* Which spreads this arrangement is for.
|
|
*
|
|
* A cover is one page rather than two, and it is the one page where the type
|
|
* is the subject: a layout built for a spread lands half of itself in the
|
|
* fold, so the two sets have to be kept apart. Everything without this is an
|
|
* inner layout, which is what all of them were.
|
|
*/
|
|
role?: 'inner' | 'single';
|
|
/** Draw the frames for a page of this size. */
|
|
build: (page: BookPageSetup) => BookTemplateSlot[];
|
|
}
|
|
|
|
export type BookTemplateSlot =
|
|
| { kind: 'photo'; frame: Frame }
|
|
| { kind: 'heading'; frame: Frame }
|
|
| { kind: 'body'; frame: Frame }
|
|
| { kind: 'meta'; frame: Frame };
|
|
|
|
interface Frame {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
}
|
|
|
|
const M = 16;
|
|
const G = 6;
|
|
|
|
function grid(page: BookPageSetup, cols: number, rows: number, opts: { bleed?: boolean } = {}): Frame[] {
|
|
const W = page.pageWidth * 2;
|
|
const H = page.pageHeight;
|
|
const pad = opts.bleed ? -page.bleed : M;
|
|
const availW = (opts.bleed ? W + page.bleed * 2 : W - M * 2) - G * (cols - 1);
|
|
const availH = (opts.bleed ? H + page.bleed * 2 : H - M * 2) - G * (rows - 1);
|
|
const cw = availW / cols;
|
|
const ch = availH / rows;
|
|
const out: Frame[] = [];
|
|
for (let r = 0; r < rows; r++) {
|
|
for (let c = 0; c < cols; c++) {
|
|
out.push({ x: pad + c * (cw + G), y: pad + r * (ch + G), w: cw, h: ch });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The spread layouts. `BOOK_COVER_TEMPLATES` holds the single-page set. */
|
|
export const BOOK_TEMPLATES: BookTemplate[] = [
|
|
{
|
|
id: 'hero-story',
|
|
labelKey: 'journey.studio.tpl.heroStory',
|
|
photoSlots: 4,
|
|
build: (page) => {
|
|
const W = page.pageWidth;
|
|
const H = page.pageHeight;
|
|
const col = W + M + 6;
|
|
const colW = W - M * 2 - 6;
|
|
const gridH = H * 0.24;
|
|
return [
|
|
// Exactly up to the gutter: see the note in autoLayout.ts.
|
|
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } },
|
|
{ kind: 'meta', frame: { x: col, y: M + 18, w: colW, h: 5 } },
|
|
{ kind: 'heading', frame: { x: col, y: M + 26, w: colW, h: 12 } },
|
|
{ kind: 'body', frame: { x: col, y: M + 44, w: colW, h: H - M * 2 - 44 - gridH - 10 } },
|
|
{ kind: 'photo', frame: { x: col, y: H - M - gridH, w: (colW - G * 2) / 3, h: gridH } },
|
|
{
|
|
kind: 'photo',
|
|
frame: { x: col + (colW - G * 2) / 3 + G, y: H - M - gridH, w: (colW - G * 2) / 3, h: gridH },
|
|
},
|
|
{
|
|
kind: 'photo',
|
|
frame: { x: col + ((colW - G * 2) / 3 + G) * 2, y: H - M - gridH, w: (colW - G * 2) / 3, h: gridH },
|
|
},
|
|
];
|
|
},
|
|
},
|
|
{
|
|
id: 'full-bleed',
|
|
labelKey: 'journey.studio.tpl.fullBleed',
|
|
photoSlots: 1,
|
|
build: (page) => [
|
|
{
|
|
kind: 'photo',
|
|
frame: {
|
|
x: -page.bleed,
|
|
y: -page.bleed,
|
|
w: page.pageWidth * 2 + page.bleed * 2,
|
|
h: page.pageHeight + page.bleed * 2,
|
|
},
|
|
},
|
|
{ kind: 'heading', frame: { x: M, y: page.pageHeight - M - 22, w: page.pageWidth - M, h: 12 } },
|
|
],
|
|
},
|
|
{
|
|
id: 'two-up',
|
|
labelKey: 'journey.studio.tpl.twoUp',
|
|
photoSlots: 2,
|
|
build: (page) => {
|
|
const H = page.pageHeight;
|
|
const W = page.pageWidth;
|
|
return [
|
|
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } },
|
|
{ kind: 'photo', frame: { x: W, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } },
|
|
];
|
|
},
|
|
},
|
|
{
|
|
id: 'grid-4',
|
|
labelKey: 'journey.studio.tpl.grid4',
|
|
photoSlots: 4,
|
|
build: (page) => grid(page, 2, 2).map((frame) => ({ kind: 'photo' as const, frame })),
|
|
},
|
|
{
|
|
id: 'grid-6',
|
|
labelKey: 'journey.studio.tpl.grid6',
|
|
photoSlots: 6,
|
|
build: (page) => grid(page, 3, 2).map((frame) => ({ kind: 'photo' as const, frame })),
|
|
},
|
|
{
|
|
id: 'strip',
|
|
labelKey: 'journey.studio.tpl.strip',
|
|
photoSlots: 3,
|
|
build: (page) => {
|
|
const H = page.pageHeight;
|
|
const W = page.pageWidth * 2;
|
|
const stripH = H * 0.52;
|
|
const cw = (W + page.bleed * 2 - G * 2) / 3;
|
|
return [
|
|
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: cw, h: stripH + page.bleed } },
|
|
{ kind: 'photo', frame: { x: -page.bleed + cw + G, y: -page.bleed, w: cw, h: stripH + page.bleed } },
|
|
{ kind: 'photo', frame: { x: -page.bleed + (cw + G) * 2, y: -page.bleed, w: cw, h: stripH + page.bleed } },
|
|
{ kind: 'meta', frame: { x: M, y: stripH + 14, w: page.pageWidth - M * 2, h: 5 } },
|
|
{ kind: 'heading', frame: { x: M, y: stripH + 22, w: page.pageWidth - M * 2, h: 12 } },
|
|
{
|
|
kind: 'body',
|
|
frame: { x: page.pageWidth + M, y: stripH + 14, w: page.pageWidth - M * 2, h: H - stripH - 14 - M },
|
|
},
|
|
];
|
|
},
|
|
},
|
|
{
|
|
id: 'quiet-text',
|
|
labelKey: 'journey.studio.tpl.quietText',
|
|
photoSlots: 0,
|
|
build: (page) => {
|
|
const W = page.pageWidth;
|
|
const H = page.pageHeight;
|
|
return [
|
|
{ kind: 'meta', frame: { x: W + M, y: M + 26, w: W - M * 2, h: 5 } },
|
|
{ kind: 'heading', frame: { x: W + M, y: M + 34, w: W - M * 2, h: 14 } },
|
|
{ kind: 'body', frame: { x: W + M, y: M + 54, w: W - M * 2, h: H - M * 2 - 54 } },
|
|
];
|
|
},
|
|
},
|
|
{
|
|
id: 'full-text',
|
|
labelKey: 'journey.studio.tpl.fullText',
|
|
photoSlots: 1,
|
|
build: (page) => {
|
|
// A picture that owns the left page and a column of story on the right,
|
|
// set narrow. The arrangement for an entry with one photograph worth
|
|
// looking at and something to say about it.
|
|
const W = page.pageWidth;
|
|
const H = page.pageHeight;
|
|
return [
|
|
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } },
|
|
{ kind: 'meta', frame: { x: W + M, y: H * 0.28, w: W - M * 2, h: 5 } },
|
|
{ kind: 'heading', frame: { x: W + M, y: H * 0.28 + 8, w: W - M * 2, h: 14 } },
|
|
{ kind: 'body', frame: { x: W + M, y: H * 0.28 + 26, w: (W - M * 2) * 0.82, h: H * 0.4 } },
|
|
];
|
|
},
|
|
},
|
|
{
|
|
id: 'grid-9',
|
|
labelKey: 'journey.studio.tpl.grid9',
|
|
photoSlots: 9,
|
|
build: (page) => grid(page, 3, 3).map((frame) => ({ kind: 'photo' as const, frame })),
|
|
},
|
|
{
|
|
id: 'mosaic',
|
|
labelKey: 'journey.studio.tpl.mosaic',
|
|
photoSlots: 5,
|
|
build: (page) => {
|
|
const W = page.pageWidth * 2;
|
|
const H = page.pageHeight;
|
|
const availW = W - M * 2 - G * 2;
|
|
const big = availW * 0.5;
|
|
const small = (availW - big) / 2;
|
|
const rowH = (H - M * 2 - G) / 2;
|
|
return [
|
|
{ kind: 'photo', frame: { x: M, y: M, w: big, h: rowH * 2 + G } },
|
|
{ kind: 'photo', frame: { x: M + big + G, y: M, w: small, h: rowH } },
|
|
{ kind: 'photo', frame: { x: M + big + G * 2 + small, y: M, w: small, h: rowH } },
|
|
{ kind: 'photo', frame: { x: M + big + G, y: M + rowH + G, w: small, h: rowH } },
|
|
{ kind: 'photo', frame: { x: M + big + G * 2 + small, y: M + rowH + G, w: small, h: rowH } },
|
|
];
|
|
},
|
|
},
|
|
{
|
|
id: 'band-quote',
|
|
labelKey: 'journey.studio.tpl.bandQuote',
|
|
photoSlots: 2,
|
|
build: (page) => {
|
|
// Two pictures with the words between them, set large. For the entry that
|
|
// said something worth reading twice.
|
|
const W = page.pageWidth;
|
|
const H = page.pageHeight;
|
|
const band = H * 0.34;
|
|
const top = (H - band) / 2;
|
|
return [
|
|
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: top + page.bleed } },
|
|
{ kind: 'photo', frame: { x: W, y: -page.bleed, w: W + page.bleed, h: top + page.bleed } },
|
|
{ kind: 'heading', frame: { x: M * 2, y: top + band * 0.24, w: W * 2 - M * 4, h: 16 } },
|
|
{ kind: 'body', frame: { x: W * 0.5, y: top + band * 0.62, w: W, h: band * 0.3 } },
|
|
];
|
|
},
|
|
},
|
|
{
|
|
id: 'stagger-four',
|
|
labelKey: 'journey.studio.tpl.staggerFour',
|
|
photoSlots: 4,
|
|
build: (page) => {
|
|
// Alternate frames drop, which is what stops four squares in a row
|
|
// reading as a contact sheet.
|
|
const W = page.pageWidth * 2;
|
|
const H = page.pageHeight;
|
|
const cw = (W - M * 2 - G * 3) / 4;
|
|
const ch = H * 0.62;
|
|
return [0, 1, 2, 3].map((i) => ({
|
|
kind: 'photo' as const,
|
|
frame: { x: M + i * (cw + G), y: M + (i % 2 ? H * 0.16 : 0), w: cw, h: ch },
|
|
}));
|
|
},
|
|
},
|
|
{
|
|
id: 'portrait-pair',
|
|
labelKey: 'journey.studio.tpl.portraitPair',
|
|
photoSlots: 2,
|
|
build: (page) => {
|
|
const W = page.pageWidth;
|
|
const H = page.pageHeight;
|
|
const pw = W - M * 2;
|
|
const ph = H - M * 2 - 24;
|
|
return [
|
|
{ kind: 'photo', frame: { x: M, y: M, w: pw, h: ph } },
|
|
{ kind: 'photo', frame: { x: W + M, y: M, w: pw, h: ph } },
|
|
{ kind: 'meta', frame: { x: M, y: H - M - 16, w: pw, h: 5 } },
|
|
{ kind: 'body', frame: { x: W + M, y: H - M - 16, w: pw, h: 12 } },
|
|
];
|
|
},
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Layouts for a single page: the cover and the back.
|
|
*
|
|
* Kept apart from the spread layouts because a cover is a different problem: it
|
|
* is one page, the type is the subject rather than a caption to a photograph,
|
|
* and the arrangements that work are the ones that leave a picture room to be
|
|
* looked at while a title sits over it legibly. A spread layout applied here
|
|
* would put half its frames past the edge of the page.
|
|
*/
|
|
export const BOOK_COVER_TEMPLATES: BookTemplate[] = [
|
|
{
|
|
id: 'cover-full',
|
|
labelKey: 'journey.studio.tpl.coverFull',
|
|
photoSlots: 1,
|
|
role: 'single',
|
|
build: (page) => [
|
|
{
|
|
kind: 'photo',
|
|
frame: {
|
|
x: -page.bleed,
|
|
y: -page.bleed,
|
|
w: page.pageWidth + page.bleed * 2,
|
|
h: page.pageHeight + page.bleed * 2,
|
|
},
|
|
},
|
|
{ kind: 'heading', frame: { x: M, y: page.pageHeight - M - 46, w: page.pageWidth - M * 2, h: 30 } },
|
|
{ kind: 'meta', frame: { x: M, y: page.pageHeight - M - 12, w: page.pageWidth - M * 2, h: 8 } },
|
|
],
|
|
},
|
|
{
|
|
id: 'cover-band',
|
|
labelKey: 'journey.studio.tpl.coverBand',
|
|
photoSlots: 1,
|
|
role: 'single',
|
|
build: (page) => {
|
|
// The picture stops short of the foot, and the title sits on the paper
|
|
// rather than on the photograph, the safest cover there is, because it
|
|
// is legible whatever the photograph turns out to be.
|
|
const h = page.pageHeight * 0.68;
|
|
return [
|
|
{
|
|
kind: 'photo',
|
|
frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth + page.bleed * 2, h: h + page.bleed },
|
|
},
|
|
{ kind: 'heading', frame: { x: M, y: h + 16, w: page.pageWidth - M * 2, h: 26 } },
|
|
{ kind: 'meta', frame: { x: M, y: h + 44, w: page.pageWidth - M * 2, h: 8 } },
|
|
];
|
|
},
|
|
},
|
|
{
|
|
id: 'cover-window',
|
|
labelKey: 'journey.studio.tpl.coverWindow',
|
|
photoSlots: 1,
|
|
role: 'single',
|
|
build: (page) => {
|
|
const inset = page.pageWidth * 0.13;
|
|
return [
|
|
{
|
|
kind: 'photo',
|
|
frame: { x: inset, y: inset, w: page.pageWidth - inset * 2, h: page.pageHeight - inset * 2.6 },
|
|
},
|
|
{
|
|
kind: 'heading',
|
|
frame: { x: inset, y: page.pageHeight - inset * 1.4, w: page.pageWidth - inset * 2, h: 20 },
|
|
},
|
|
];
|
|
},
|
|
},
|
|
{
|
|
id: 'cover-quiet',
|
|
labelKey: 'journey.studio.tpl.coverQuiet',
|
|
photoSlots: 0,
|
|
role: 'single',
|
|
build: (page) => [
|
|
{ kind: 'heading', frame: { x: M, y: page.pageHeight * 0.42, w: page.pageWidth - M * 2, h: 28 } },
|
|
{ kind: 'meta', frame: { x: M, y: page.pageHeight * 0.42 + 32, w: page.pageWidth - M * 2, h: 8 } },
|
|
],
|
|
},
|
|
{
|
|
id: 'cover-half',
|
|
labelKey: 'journey.studio.tpl.coverHalf',
|
|
photoSlots: 2,
|
|
role: 'single',
|
|
build: (page) => {
|
|
const half = page.pageHeight * 0.5;
|
|
return [
|
|
{
|
|
kind: 'photo',
|
|
frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth + page.bleed * 2, h: half + page.bleed },
|
|
},
|
|
{
|
|
kind: 'photo',
|
|
frame: {
|
|
x: -page.bleed,
|
|
y: half,
|
|
w: page.pageWidth + page.bleed * 2,
|
|
h: page.pageHeight - half + page.bleed,
|
|
},
|
|
},
|
|
{ kind: 'heading', frame: { x: M, y: half - 34, w: page.pageWidth - M * 2, h: 26 } },
|
|
];
|
|
},
|
|
},
|
|
];
|
|
|
|
/** A template by id, from either set, or undefined. */
|
|
export function findBookTemplate(id: string): BookTemplate | undefined {
|
|
return BOOK_TEMPLATES.find((t) => t.id === id) ?? BOOK_COVER_TEMPLATES.find((t) => t.id === id);
|
|
}
|
|
|
|
/** The text styles a template slot is set in, by its kind. */
|
|
export const BOOK_TEXT_STYLES = {
|
|
heading: { size: 22, weight: 700 as const, leading: 1.1, tracking: -0.02, color: '#141414' },
|
|
meta: { size: 7.5, weight: 600 as const, tracking: 0.14, color: '#8a8578' },
|
|
body: { size: 10, weight: 400 as const, leading: 1.6, color: '#2a2a2a' },
|
|
};
|
|
const HEADING = BOOK_TEXT_STYLES.heading;
|
|
const META = BOOK_TEXT_STYLES.meta;
|
|
const BODY = BOOK_TEXT_STYLES.body;
|
|
|
|
function textEl(
|
|
frame: Frame,
|
|
text: string,
|
|
style: typeof HEADING | typeof META | typeof BODY,
|
|
source: BookElement | null,
|
|
): BookElement {
|
|
const base = source && source.kind === 'text' ? source : null;
|
|
return {
|
|
id: bookElementId('t'),
|
|
kind: 'text',
|
|
frame,
|
|
rotation: 0,
|
|
opacity: 1,
|
|
locked: false,
|
|
text,
|
|
font: base?.font ?? 'sans',
|
|
italic: false,
|
|
align: base?.align ?? 'left',
|
|
tracking: 0,
|
|
binding: base?.binding ?? null,
|
|
overridden: base?.overridden ?? false,
|
|
...style,
|
|
} as BookElement;
|
|
}
|
|
|
|
/**
|
|
* Which of a spread's texts is its heading, its story and its date line.
|
|
*
|
|
* Size decides the heading first, and length only sorts what is left. The
|
|
* other way round, longest is the story, then look for a heading among the
|
|
* rest, reads a spread with three texts correctly and gets a cover with one
|
|
* exactly wrong: the single 30pt title becomes "the longest text", so it is
|
|
* the story, so a cover layout with a heading slot and no body slot has
|
|
* nowhere to put it and parks the title.
|
|
*/
|
|
export function classifyBookTexts(texts: readonly BookElement[]): {
|
|
heading: BookElement | null;
|
|
body: BookElement | null;
|
|
meta: BookElement | null;
|
|
} {
|
|
const heading =
|
|
[...texts]
|
|
.filter((t) => t.kind === 'text' && t.size >= 14)
|
|
.sort((a, b) => (b.kind === 'text' ? b.size : 0) - (a.kind === 'text' ? a.size : 0))[0] ?? null;
|
|
const rest = texts.filter((t) => t !== heading);
|
|
const body =
|
|
[...rest].sort((a, b) => (b.kind === 'text' ? b.text.length : 0) - (a.kind === 'text' ? a.text.length : 0))[0] ??
|
|
null;
|
|
const meta = rest.find((t) => t !== body) ?? null;
|
|
return { heading, body, meta };
|
|
}
|
|
|
|
/**
|
|
* Pour the spread's existing content into a different arrangement.
|
|
*
|
|
* Order is kept, because order is the only thing that survives a layout change
|
|
* with its meaning intact: the first photo stays the first photo. Text keeps its
|
|
* binding to the journal entry, so a re-laid page still updates when the entry
|
|
* is edited.
|
|
*/
|
|
export function applyBookTemplate(spread: BookSpread, tpl: BookTemplate, page: BookPageSetup): BookSpread {
|
|
const slots = tpl.build(page);
|
|
// Placed content first, then whatever an earlier layout had to park: the
|
|
// pictures on the page keep their order, and the ones that were set aside
|
|
// queue up behind them.
|
|
const pool = [...spread.elements, ...(spread.parked ?? [])];
|
|
const photos = pool.filter((e) => e.kind === 'photo');
|
|
const texts = pool.filter((e) => e.kind === 'text');
|
|
const { heading, body, meta } = classifyBookTexts(texts);
|
|
|
|
let pi = 0;
|
|
const out: BookElement[] = [];
|
|
const usedText = new Set<BookElement>();
|
|
|
|
for (const slot of slots) {
|
|
if (slot.kind === 'photo') {
|
|
const src = photos[pi++];
|
|
out.push({
|
|
id: bookElementId('p'),
|
|
kind: 'photo',
|
|
frame: slot.frame,
|
|
rotation: 0,
|
|
opacity: 1,
|
|
locked: false,
|
|
photoId: src && src.kind === 'photo' ? src.photoId : null,
|
|
fit: src && src.kind === 'photo' ? src.fit : 'cover',
|
|
focalX: src && src.kind === 'photo' ? src.focalX : 0.5,
|
|
focalY: src && src.kind === 'photo' ? src.focalY : 0.5,
|
|
radius: src && src.kind === 'photo' ? src.radius : 0,
|
|
filter: src && src.kind === 'photo' ? src.filter : 'none',
|
|
// The cut and the decoration travel with the picture. A layout change
|
|
// rearranges a page; it is not a reason for a photograph to come out of
|
|
// its Polaroid.
|
|
mask: src && src.kind === 'photo' ? src.mask : null,
|
|
frameStyle: src && src.kind === 'photo' ? src.frameStyle : 'none',
|
|
} as BookElement);
|
|
continue;
|
|
}
|
|
|
|
const src = slot.kind === 'heading' ? heading : slot.kind === 'meta' ? meta : body;
|
|
const value = src && src.kind === 'text' ? src.text : '';
|
|
if (!value) continue;
|
|
if (src) usedText.add(src);
|
|
out.push(textEl(slot.frame, value, slot.kind === 'heading' ? HEADING : slot.kind === 'meta' ? META : BODY, src));
|
|
}
|
|
|
|
// Everything the arrangement could not take. An empty photo frame carries no
|
|
// picture, so it is not worth parking; a photo with an id and a text that was
|
|
// never placed are.
|
|
const parked = [
|
|
...photos.slice(pi).filter((e) => e.kind === 'photo' && e.photoId != null),
|
|
...texts.filter((e) => !usedText.has(e) && e.kind === 'text' && e.text.trim()),
|
|
].slice(0, 60);
|
|
|
|
return { ...spread, elements: out, parked };
|
|
}
|