Files
TREK/shared/src/book/book-ops-elements.ts
T
Maurice 317c1ee5ad fix(journey): the day mark reads as its language writes it, and a country mark names its country by one rule
The day word of every language now says where the number goes ("DAY {day}",
"{day}日目", "第{day}天", "{day}. GÜN"), and one helper builds the mark for the
engine, the auto layout, a template and the Travel panel, so Japanese,
Korean and Chinese no longer read "日目 3". A word stored without the
placeholder is followed by the number as before. The panel's own day word is
gone with its only reader.

A country mark carries the name as its words beside the outline and a flag
mark sets it small beside the flag, the same for the engine's value, the
panel's tile, the assistant's add and a template's country mark; a flag in a
template keeps the place its name was drawn in. A flag or country mark asked
for by its own code is named after that country without the journey having
to name one.
2026-10-23 04:21:01 +00:00

615 lines
26 KiB
TypeScript

import { relayoutSpread } from './auto/auto-book';
import type { AutoEntry, AutoInput } from './auto/auto-types';
import { bookElementPropsSchema, type BOOK_ELEMENT_OP_SCHEMAS, type BookNewElement } from './book-element-ops.schema';
import { bookGridElements, defaultBookGridBox, findBookGrid } from './book-grids';
import {
bookBoundWords,
failOp,
galleryPhoto,
journeyEntry,
pageElement,
roomFor,
round2,
unlockedElement,
type BookOpsContext,
} from './book-ops-core';
import {
bookBadgeValue,
bookCountryMark,
bookCountryName,
bookEntryListItems,
bookTravelBadge,
bookTravelCountries,
bookTravelList,
bookTravelMap,
bookTravelSize,
bookTravelStats,
refreshPatch,
type BookTravelContext,
} from './book-travel';
import type {
BookDocument,
BookElement,
BookFrame,
BookPageSetup,
BookPhotoElement,
BookSpread,
BookTextElement,
} from './book.schema';
import type { z } from 'zod';
/**
* The element operations of the book engine: what `add`, `style`, `arrange`,
* `align`, `distribute`, `lock`, `duplicate`, `fill`, `copy_from`,
* `refresh_travel` and `add_grid` do to a page, and the `auto` layout. Called
* from `applyBookOps` (book-ops.ts), which parses, runs a call all or nothing
* and checks the page it hands back against the document's schema.
*
* Each does what the Studio's own control does: the sizes an element arrives
* at are the panels', `fill` is the inspector's, `duplicate` offsets by 4 mm
* like the Studio's. Where the assistant may do more than the Studio
* (aligning and distributing a group), it is said in the operation's schema.
*/
interface ElementOpEnv {
doc: BookDocument;
page: BookPageSetup;
ctx: BookOpsContext;
newId: (prefix: string) => string;
opIndex: number;
}
type ElementOp = z.infer<(typeof BOOK_ELEMENT_OP_SCHEMAS)[number]>;
type Op<K extends ElementOp['op']> = Extract<ElementOp, { op: K }>;
export function applyElementOp(spread: BookSpread, op: ElementOp, env: ElementOpEnv): BookSpread {
switch (op.op) {
case 'add':
return opAdd(spread, op, env);
case 'style':
return opStyle(spread, op, env);
case 'arrange':
return opArrange(spread, op, env);
case 'align':
return opAlign(spread, op, env);
case 'distribute':
return opDistribute(spread, op, env);
case 'lock':
return opLock(spread, op, env);
case 'duplicate':
return opDuplicate(spread, op, env);
case 'fill':
return opFill(spread, op, env);
case 'copy_from':
return opCopyFrom(spread, op, env);
case 'refresh_travel':
return opRefreshTravel(spread, op, env);
case 'add_grid':
return opAddGrid(spread, op, env);
}
}
// ── helpers ────────────────────────────────────────────────────────────────
const isInner = (spread: BookSpread) => spread.role === 'inner';
/** A size centred on the page: the right-hand page of a spread, so a new element never lands in the fold. */
function centred(spread: BookSpread, page: BookPageSetup, size: { w: number; h: number }): BookFrame {
const origin = isInner(spread) ? page.pageWidth : 0;
return {
x: round2(origin + (page.pageWidth - size.w) / 2),
y: round2((page.pageHeight - size.h) / 2),
w: round2(size.w),
h: round2(size.h),
};
}
const roundFrame = (f: BookFrame): BookFrame => ({ x: round2(f.x), y: round2(f.y), w: round2(f.w), h: round2(f.h) });
/** The unlocked elements an operation names, each on the page, in the order named, each once. */
function namedElements(spread: BookSpread, ids: readonly string[], env: ElementOpEnv): BookElement[] {
return [...new Set(ids)].map((id) => unlockedElement(pageElement(spread, id, env.opIndex), env.opIndex));
}
function replaceAll(spread: BookSpread, next: readonly BookElement[]): BookSpread {
const byId = new Map(next.map((e) => [e.id, e]));
return { ...spread, elements: spread.elements.map((e) => byId.get(e.id) ?? e) };
}
function travelOf(env: ElementOpEnv): BookTravelContext {
const travel = env.ctx.travel;
if (!travel) throw failOp('travel_unavailable', "the journey's figures are not available here", env.opIndex);
return travel;
}
function statsOf(env: ElementOpEnv) {
const stats = travelOf(env).stats;
if (!stats) {
throw failOp('travel_unavailable', 'this journey has no figures yet (no stops with places)', env.opIndex);
}
return stats;
}
/** The look fields a kind takes, from the props schema, so `add` and `style` set exactly what `style` may set. */
const PROP_KEYS = new Map(bookElementPropsSchema.options.map((o) => [o.shape.kind.value, lookKeys(o.shape)]));
function lookKeys(shape: object): string[] {
return Object.keys(shape).filter((k) => k !== 'kind');
}
/** The element with the given look fields set; millimetre fields rounded as the document stores them. */
function withProps(el: BookElement, props: Record<string, unknown>): BookElement {
const keys = PROP_KEYS.get(el.kind) ?? [];
const next: Record<string, unknown> = { ...el };
for (const key of keys) {
const value = props[key];
if (value === undefined) continue;
next[key] = (key === 'radius' || key === 'strokeWidth') && typeof value === 'number' ? round2(value) : value;
}
return next as unknown as BookElement;
}
// ── add ────────────────────────────────────────────────────────────────────
function opAdd(spread: BookSpread, op: Op<'add'>, env: ElementOpEnv): BookSpread {
roomFor(spread, 1, env.opIndex);
const made = withProps(created(spread, op.element, op.frame ? roundFrame(op.frame) : null, env), lookOf(op.element));
const el = op.rotation !== undefined ? { ...made, rotation: op.rotation } : made;
const parked =
el.kind === 'photo' && el.photoId != null
? spread.parked.filter((p) => !(p.kind === 'photo' && p.photoId === el.photoId))
: spread.parked;
return { ...spread, elements: [...spread.elements, el], parked };
}
/** The look fields of a new element. A shape's name is `created`'s: a rule arrives as a hairline rect, never `line`. */
function lookOf(spec: BookNewElement): Record<string, unknown> {
const look: Record<string, unknown> = { ...spec };
if (spec.kind === 'shape') delete look.shape;
return look;
}
const BASE = { rotation: 0, opacity: 1, locked: false };
function created(spread: BookSpread, spec: BookNewElement, frame: BookFrame | null, env: ElementOpEnv): BookElement {
const at = (size: { w: number; h: number }) => frame ?? centred(spread, env.page, size);
switch (spec.kind) {
case 'photo':
return newPhotoFrame(spec, at, env);
case 'text':
return newText(spec, at, env);
case 'shape':
return newShape(spec, frame, spread, env);
case 'icon': {
const side = Math.min(env.page.pageWidth, env.page.pageHeight) * 0.18;
return {
...BASE,
id: env.newId('i'),
kind: 'icon',
frame: at({ w: side, h: side }),
name: spec.name,
color: '#111827',
lineWidth: 2,
};
}
default:
return newTravel(spec, at, env);
}
}
type At = (size: { w: number; h: number }) => BookFrame;
function newPhotoFrame(spec: Extract<BookNewElement, { kind: 'photo' }>, at: At, env: ElementOpEnv): BookPhotoElement {
if (spec.photoId !== undefined) galleryPhoto(spec.photoId, env.ctx, env.opIndex);
const side = Math.min(env.page.pageWidth, env.page.pageHeight) * 0.45;
// A Polaroid is taller than it is wide, because of the chin.
const frame = at({ w: side, h: spec.frameStyle === 'polaroid' ? side * 1.16 : side });
return {
...BASE,
id: env.newId('p'),
kind: 'photo',
frame,
photoId: spec.photoId ?? null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
};
}
/** The Studio's text presets: size, weight and the colour a caption and a quotation mark take. */
const TEXT_PRESETS = {
heading: { size: 30, weight: 700 },
subheading: { size: 16, weight: 600 },
body: { size: 10, weight: 400 },
caption: { size: 7.5, weight: 600, tracking: 0.14, color: '#8a8578' },
quote: { size: 46, weight: 700, color: '#c9c2b4', leading: 0.9 },
} as const satisfies Record<string, Partial<BookTextElement>>;
function newText(spec: Extract<BookNewElement, { kind: 'text' }>, at: At, env: ElementOpEnv): BookTextElement {
const binding = spec.binding ?? null;
if (binding?.photoId !== undefined) galleryPhoto(binding.photoId, env.ctx, env.opIndex);
if (binding?.entryId !== undefined) journeyEntry(binding.entryId, env.ctx, env.opIndex);
const read = binding ? bookBoundWords(binding, env.ctx) : undefined;
const words = spec.text ?? read;
if (words === undefined || !words.trim()) {
throw failOp('text_required', 'give the words for the new text; its binding cannot be read here', env.opIndex);
}
const preset: Partial<BookTextElement> = TEXT_PRESETS[spec.preset ?? 'body'];
const size = preset.size ?? 10;
return {
...BASE,
id: env.newId('t'),
kind: 'text',
frame: at({ w: env.page.pageWidth * 0.7, h: size * 0.5 + 8 }),
text: words,
font: 'sans',
size,
weight: preset.weight ?? 400,
italic: false,
align: 'left',
leading: preset.leading ?? (size > 16 ? 1.1 : 1.5),
tracking: preset.tracking ?? (size > 16 ? -0.02 : 0),
color: preset.color ?? '#1a1a1a',
binding,
overridden: binding !== null && spec.text !== undefined && read !== spec.text,
};
}
const WIDE_SHAPES: readonly string[] = ['ticket', 'wave', 'arrow-right', 'arrow-left', 'arrow-both', 'capsule'];
const TALL_SHAPES: readonly string[] = ['pin', 'drop', 'arrow-up', 'arrow-down'];
/** Placed at a size that suits the shape: a banner wide, a pin tall, a rule a hairline across half a page. */
function newShape(
spec: Extract<BookNewElement, { kind: 'shape' }>,
frame: BookFrame | null,
spread: BookSpread,
env: ElementOpEnv,
): BookElement {
const s = spec.shape;
const side = Math.min(env.page.pageWidth, env.page.pageHeight) * 0.3;
const wide = s.startsWith('banner') || WIDE_SHAPES.includes(s);
const tall = TALL_SHAPES.includes(s);
const rule = s === 'line';
const size = rule
? { w: env.page.pageWidth * 0.5, h: 0.5 }
: { w: wide ? side * 1.7 : side, h: tall ? side * 1.35 : wide ? side * 0.55 : side };
return {
...BASE,
id: env.newId('s'),
kind: 'shape',
frame: frame ?? centred(spread, env.page, size),
// A rule is a hairline box rather than its own element type, as in the Studio.
shape: rule ? 'rect' : s,
fill: '#141414',
gradient: 'none',
stroke: null,
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
};
}
type TravelSpec = Extract<BookNewElement, { kind: 'map' | 'stats' | 'countries' | 'badge' | 'list' }>;
function newTravel(spec: TravelSpec, at: At, env: ElementOpEnv): BookElement {
const id = env.newId(spec.kind === 'stats' ? 'st' : spec.kind.slice(0, 2));
const page = env.page;
switch (spec.kind) {
case 'map': {
const frame = at(bookTravelSize('map', page));
const el = bookTravelMap(travelOf(env), statsOf(env), { id, frame, scope: spec.scope, source: spec.source });
if (el.points.length === 0) {
throw failOp('travel_unavailable', 'no stops of the journey fall in that scope', env.opIndex);
}
return el;
}
case 'stats':
return bookTravelStats(statsOf(env), { id, frame: at(bookTravelSize('stats', page)), metrics: spec.metrics });
case 'countries': {
const travel = travelOf(env);
const el = bookTravelCountries(travel, { id, frame: at(bookTravelSize('countries', page)), codes: spec.codes });
if (el.codes.length === 0) {
throw failOp('travel_unavailable', 'the journey has no countries to show', env.opIndex);
}
return el;
}
case 'badge':
return newBadge(spec, id, at, env);
case 'list':
return newList(spec, id, at, env);
}
}
/** The journey's figures when an entry is named (an entry needs them to be found), else whatever is known. */
const travelFor = (entryId: number | undefined, env: ElementOpEnv) =>
entryId !== undefined ? travelOf(env) : env.ctx.travel;
function newBadge(spec: Extract<TravelSpec, { kind: 'badge' }>, id: string, at: At, env: ElementOpEnv): BookElement {
const travel = travelFor(spec.entryId, env);
const entry = spec.entryId !== undefined ? (travel?.entries.find((e) => e.id === spec.entryId) ?? null) : null;
if (spec.entryId !== undefined && !entry) {
throw failOp('entry_not_found', `entry ${spec.entryId} is not in this journey`, env.opIndex);
}
const resolved = travel ? badgeValue(spec, travel, entry) : null;
const ownCode = spec.variant === 'mood' ? entry?.mood : spec.variant === 'weather' ? entry?.weather : undefined;
const text = spec.text ?? resolved?.text;
if (text === undefined || (!text && !resolved)) {
const why = travel ? 'nothing in the journey says what it would show' : "the journey's figures are not available";
throw failOp('text_required', `give the words for the ${spec.variant} mark; ${why}`, env.opIndex);
}
const value = {
text,
sub: spec.sub ?? resolved?.sub ?? '',
code: spec.code ?? ownCode ?? resolved?.code ?? null,
};
const frame = at(bookTravelSize('badge', env.page, { variant: spec.variant }));
return bookTravelBadge({ id, frame, variant: spec.variant, value });
}
/**
* The words the journey gives a mark. A flag or country mark asked for by its
* own code is labelled with that country, its name where the mark's rule puts
* it (`bookCountryMark`); without a code it shows the entry's country or the
* journey's first.
*/
function badgeValue(spec: Extract<TravelSpec, { kind: 'badge' }>, travel: BookTravelContext, entry: AutoEntry | null) {
const { variant, code } = spec;
if (code && (variant === 'flag' || variant === 'country')) {
return bookCountryMark(variant, code, bookCountryName(code, travel.locale, travel.stats));
}
return bookBadgeValue(travel, variant, entry);
}
function newList(spec: Extract<TravelSpec, { kind: 'list' }>, id: string, at: At, env: ElementOpEnv): BookElement {
const travel = travelFor(spec.entryId, env);
let items = spec.items ?? [];
if (spec.entryId !== undefined) {
const entry = travel?.entries.find((e) => e.id === spec.entryId);
if (!entry) throw failOp('entry_not_found', `entry ${spec.entryId} is not in this journey`, env.opIndex);
items = bookEntryListItems(entry);
if (!items.length) {
throw failOp('text_required', `entry ${spec.entryId} has no pros or cons; give the items`, env.opIndex);
}
}
const labels = { pros: travel?.labels.pros ?? '', cons: travel?.labels.cons ?? '' };
const rows = Math.max(items.filter((i) => i.tone === 'pro').length, items.filter((i) => i.tone !== 'pro').length);
const frame = at(bookTravelSize('list', env.page, { rows }));
return bookTravelList(labels, { id, frame, items });
}
// ── style, lock ────────────────────────────────────────────────────────────
function opStyle(spread: BookSpread, op: Op<'style'>, env: ElementOpEnv): BookSpread {
const el = unlockedElement(pageElement(spread, op.elementId, env.opIndex), env.opIndex);
if (op.set.kind !== el.kind) {
throw failOp('wrong_kind', `${el.id} is a ${el.kind}; set.kind says ${op.set.kind}`, env.opIndex, el.id);
}
return replaceAll(spread, [withProps(el, op.set)]);
}
function opLock(spread: BookSpread, op: Op<'lock'>, env: ElementOpEnv): BookSpread {
const els = [...new Set(op.elementIds)].map((id) => pageElement(spread, id, env.opIndex));
return replaceAll(
spread,
els.map((e) => ({ ...e, locked: op.locked })),
);
}
// ── arrange ────────────────────────────────────────────────────────────────
function opArrange(spread: BookSpread, op: Op<'arrange'>, env: ElementOpEnv): BookSpread {
const chosen = new Set(namedElements(spread, op.elementIds, env).map((e) => e.id));
const pick = (want: boolean) => spread.elements.filter((e) => chosen.has(e.id) === want);
if (op.to === 'front') return { ...spread, elements: [...pick(false), ...pick(true)] };
if (op.to === 'back') return { ...spread, elements: [...pick(true), ...pick(false)] };
const els = spread.elements.slice();
const step = op.to === 'forward' ? 1 : -1;
const order = els.map((_, i) => i);
if (step === 1) order.reverse();
for (const i of order) {
const here = els[i];
const there = els[i + step];
if (!here || !there || !chosen.has(here.id) || chosen.has(there.id)) continue;
els[i] = there;
els[i + step] = here;
}
return { ...spread, elements: els };
}
// ── align, distribute ──────────────────────────────────────────────────────
/** A frame in millimetres, as the book types it, so a zod-inferred `el.frame` passes in every compile mode. */
type Box = BookFrame;
function boundsOf(frames: readonly Box[]): Box {
const x = Math.min(...frames.map((f) => f.x));
const y = Math.min(...frames.map((f) => f.y));
const right = Math.max(...frames.map((f) => f.x + f.w));
const bottom = Math.max(...frames.map((f) => f.y + f.h));
return { x, y, w: right - x, h: bottom - y };
}
/** The page an element's centre is on, or its safe area. */
function pageBox(spread: BookSpread, page: BookPageSetup, f: Box, safe: boolean): Box {
const onRight = isInner(spread) && f.x + f.w / 2 >= page.pageWidth;
const x = onRight ? page.pageWidth : 0;
const inset = safe ? page.safe : 0;
return { x: x + inset, y: inset, w: page.pageWidth - inset * 2, h: page.pageHeight - inset * 2 };
}
function alignedFrame(f: Box, box: Box, edge: Op<'align'>['edge']): BookFrame {
switch (edge) {
case 'left':
return { ...f, x: round2(box.x) };
case 'center':
return { ...f, x: round2(box.x + (box.w - f.w) / 2) };
case 'right':
return { ...f, x: round2(box.x + box.w - f.w) };
case 'top':
return { ...f, y: round2(box.y) };
case 'middle':
return { ...f, y: round2(box.y + (box.h - f.h) / 2) };
case 'bottom':
return { ...f, y: round2(box.y + box.h - f.h) };
}
}
function opAlign(spread: BookSpread, op: Op<'align'>, env: ElementOpEnv): BookSpread {
const els = namedElements(spread, op.elementIds, env);
const selection = boundsOf(els.map((e) => e.frame));
const next = els.map((e) => {
const box = op.to === 'selection' ? selection : pageBox(spread, env.page, e.frame, op.to === 'safe');
return { ...e, frame: alignedFrame(e.frame, box, op.edge) } as BookElement;
});
return replaceAll(spread, next);
}
function opDistribute(spread: BookSpread, op: Op<'distribute'>, env: ElementOpEnv): BookSpread {
const els = namedElements(spread, op.elementIds, env);
if (els.length < 3) throw failOp('not_enough_elements', 'distribute needs three different elements', env.opIndex);
const across = op.axis === 'horizontal';
const start = (f: Box) => (across ? f.x : f.y);
const size = (f: Box) => (across ? f.w : f.h);
const sorted = els.slice().sort((a, b) => start(a.frame) - start(b.frame));
const first = sorted[0] as BookElement;
const last = sorted[sorted.length - 1] as BookElement;
const total = sorted.reduce((sum, e) => sum + size(e.frame), 0);
const span = start(last.frame) + size(last.frame) - start(first.frame);
const gap = op.gap ?? (span - total) / (sorted.length - 1);
let cursor = start(first.frame);
const next = sorted.map((e) => {
const at = round2(cursor);
cursor += size(e.frame) + gap;
return { ...e, frame: across ? { ...e.frame, x: at } : { ...e.frame, y: at } } as BookElement;
});
return replaceAll(spread, next);
}
// ── duplicate, fill, copy_from ─────────────────────────────────────────────
const prefixOf = (el: BookElement) => el.kind.charAt(0);
function opDuplicate(spread: BookSpread, op: Op<'duplicate'>, env: ElementOpEnv): BookSpread {
const els = [...new Set(op.elementIds)].map((id) => pageElement(spread, id, env.opIndex));
roomFor(spread, els.length, env.opIndex);
const dx = op.dx ?? 4;
const dy = op.dy ?? 4;
const copies = els.map((e) => {
const frame = { ...e.frame, x: round2(e.frame.x + dx), y: round2(e.frame.y + dy) };
return { ...e, id: env.newId(prefixOf(e)), frame } as BookElement;
});
return { ...spread, elements: [...spread.elements, ...copies] };
}
/** The inspector's fill: the photo to the bleed of the page its centre is on, or of the spread, at the back. */
function opFill(spread: BookSpread, op: Op<'fill'>, env: ElementOpEnv): BookSpread {
const el = unlockedElement(pageElement(spread, op.elementId, env.opIndex), env.opIndex);
if (el.kind !== 'photo') {
throw failOp('wrong_kind', `${el.id} is a ${el.kind}; fill takes a photo`, env.opIndex, el.id);
}
const { bleed, pageWidth, pageHeight } = env.page;
const onRight = isInner(spread) && op.scope === 'page' && el.frame.x + el.frame.w / 2 >= pageWidth;
const spanning = op.scope === 'spread' && isInner(spread);
const frame = {
x: onRight ? pageWidth - bleed : -bleed,
y: -bleed,
w: (spanning ? 2 : 1) * pageWidth + 2 * bleed,
h: pageHeight + 2 * bleed,
};
const filled: BookElement = { ...el, frame, rotation: 0, fit: 'cover' };
return { ...spread, elements: [filled, ...spread.elements.filter((e) => e.id !== el.id)] };
}
/** Which page of its spread an element is on: the left (or only) page is 0, the right one starts at the fold. */
const pageOrigin = (spread: BookSpread, page: BookPageSetup, f: Box) =>
isInner(spread) && f.x + f.w / 2 >= page.pageWidth ? page.pageWidth : 0;
function opCopyFrom(spread: BookSpread, op: Op<'copy_from'>, env: ElementOpEnv): BookSpread {
const source = env.doc.spreads.find((s) => s.id === op.spreadId);
if (!source) throw failOp('spread_not_found', `no page ${op.spreadId}`, env.opIndex);
const els = [...new Set(op.elementIds)].map((id) => pageElement(source, id, env.opIndex));
roomFor(spread, els.length, env.opIndex);
const sameKind = isInner(source) === isInner(spread);
const target = isInner(spread) ? env.page.pageWidth : 0;
const copies = els.map((e) => {
if (e.kind === 'photo' && e.photoId != null) galleryPhoto(e.photoId, env.ctx, env.opIndex);
const x = sameKind ? e.frame.x : e.frame.x - pageOrigin(source, env.page, e.frame) + target;
return { ...e, id: env.newId(prefixOf(e)), frame: { ...e.frame, x: round2(x) } } as BookElement;
});
return { ...spread, elements: [...spread.elements, ...copies] };
}
// ── refresh_travel, add_grid ───────────────────────────────────────────────
const carriesFigures = (el: BookElement) => el.kind === 'stats' || el.kind === 'countries' || el.kind === 'map';
function refreshTargets(spread: BookSpread, ids: readonly string[] | undefined, env: ElementOpEnv): BookElement[] {
if (!ids) return spread.elements.filter((el) => carriesFigures(el) && !el.locked);
const named = namedElements(spread, ids, env);
const other = named.find((el) => !carriesFigures(el));
if (other) {
throw failOp('wrong_kind', `${other.id} is a ${other.kind}; it carries no journey figures`, env.opIndex, other.id);
}
return named;
}
function refreshed(el: BookElement, patch: Partial<BookElement> | null): BookElement {
return { ...el, ...patch } as BookElement;
}
function opRefreshTravel(spread: BookSpread, op: Op<'refresh_travel'>, env: ElementOpEnv): BookSpread {
const stats = statsOf(env);
const next = refreshTargets(spread, op.elementIds, env).map((el) => refreshed(el, refreshPatch(el, stats)));
return replaceAll(spread, next);
}
function opAddGrid(spread: BookSpread, op: Op<'add_grid'>, env: ElementOpEnv): BookSpread {
const grid = findBookGrid(op.grid);
if (!grid) throw failOp('layout_not_found', `no grid ${op.grid}`, env.opIndex);
const box = op.box ?? defaultBookGridBox(env.page, !isInner(spread));
const cells = bookGridElements(grid, box).map((c) => ({ ...c, id: env.newId('p'), frame: roundFrame(c.frame) }));
roomFor(spread, cells.length, env.opIndex);
return { ...spread, elements: [...spread.elements, ...cells] };
}
// ── layout auto ────────────────────────────────────────────────────────────
/**
* The Studio's "lay this page out again": rebuilt from the entry the page was
* made from, with the journey's figures. The page keeps its id and what it had
* parked; the caller keeps locked elements where they were.
*/
export function autoRelayout(spread: BookSpread, env: ElementOpEnv): BookSpread {
const travel = travelOf(env);
if (spread.entryId == null || !isInner(spread)) {
throw failOp('layout_not_found', 'this page was not made from an entry; auto layout needs one', env.opIndex);
}
if (!travel.entries.some((e) => e.id === spread.entryId)) {
throw failOp('entry_not_found', `entry ${spread.entryId} is not in this journey`, env.opIndex);
}
const journey = env.ctx.journey;
const input: AutoInput = {
locale: travel.locale,
title: journey?.title ?? env.doc.title,
subtitle: journey?.subtitle ?? null,
coverPhotoId: journey?.coverPhotoId ?? null,
entries: [...travel.entries],
page: env.page,
stats: travel.stats,
stationsLabel: travel.labels.stations,
dayLabel: travel.labels.day,
path: travel.path,
summaryLabel: travel.labels.summary,
countriesLabel: travel.labels.countries,
newId: env.newId,
};
const next = relayoutSpread(spread, input);
if (!next) throw failOp('layout_not_found', 'this page cannot be laid out again from its entry', env.opIndex);
return { ...next, parked: spread.parked };
}