mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
317c1ee5ad
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.
615 lines
26 KiB
TypeScript
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 };
|
|
}
|