feat(studio): the book engine in shared: layouts, grids, page ops and findings

This commit is contained in:
Maurice
2026-10-22 12:14:22 +02:00
parent acd3b3aafc
commit d2a2728cde
40 changed files with 6450 additions and 961 deletions
-6
View File
@@ -977,7 +977,6 @@
"src/components/Studio/autoLayout.ts",
"src/components/Studio/bookFontFaces.ts",
"src/components/Studio/bookFonts.ts",
"src/components/Studio/bookIds.ts",
"src/components/Studio/bookRender.ts",
"src/components/Studio/bookSheets.ts",
"src/components/Studio/bookTemplates.data.ts",
@@ -985,7 +984,6 @@
"src/components/Studio/entryText.ts",
"src/components/Studio/flags.ts",
"src/components/Studio/folioColour.ts",
"src/components/Studio/grids.ts",
"src/components/Studio/iconLibrary.ts",
"src/components/Studio/mapSources.ts",
"src/components/Studio/mapTiles.ts",
@@ -995,12 +993,10 @@
"src/components/Studio/printSheets.ts",
"src/components/Studio/resolveBindings.ts",
"src/components/Studio/roadRoute.ts",
"src/components/Studio/savedLayouts.ts",
"src/components/Studio/shapes.ts",
"src/components/Studio/spreadFile.ts",
"src/components/Studio/studioClipboard.ts",
"src/components/Studio/studioUpload.ts",
"src/components/Studio/templates.ts",
"src/components/Studio/travelRefresh.ts",
"src/components/Studio/useBookPresence.ts",
"src/components/Studio/useBookStore.ts",
@@ -2114,7 +2110,6 @@
"tests/unit/studio/entryText.test.ts",
"tests/unit/studio/flags.test.ts",
"tests/unit/studio/folioColour.test.ts",
"tests/unit/studio/grids.test.ts",
"tests/unit/studio/iconElement.test.tsx",
"tests/unit/studio/inspector.test.tsx",
"tests/unit/studio/inspectorTravel.test.tsx",
@@ -2138,7 +2133,6 @@
"tests/unit/studio/studioClipboardLayouts.test.ts",
"tests/unit/studio/studioStore.spreads.test.ts",
"tests/unit/studio/studioUpload.test.ts",
"tests/unit/studio/templates.test.ts",
"tests/unit/studio/travelRefresh.test.ts",
"tests/unit/studio/travelStops.test.tsx",
"tests/unit/studio/trimField.test.tsx",
+1 -1
View File
@@ -56,6 +56,6 @@
"src/hooks/useWebPush.ts": 1,
"src/repo/pluginPoiRepo.ts": 1,
"src/store/slices/budgetSlice.ts": 1,
"src/store/studioStore.ts": 2,
"src/store/studioStore.ts": 1,
"src/store/vacayStore.ts": 1
}
+1 -1
View File
@@ -17,7 +17,7 @@
"src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/autoLayout.ts": 1691,
"src/components/Studio/autoLayout.ts": 1675,
"src/components/Studio/bookTemplates.data.ts": 1309,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
-1
View File
@@ -108,7 +108,6 @@
"components/Studio/StudioTravelPanel.tsx": 1,
"components/Studio/TravelElements.tsx": 5,
"components/Studio/printSheets.ts": 5,
"components/Studio/templates.ts": 3,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
"components/SystemNotices/SystemNoticeModal.tsx": 107,
@@ -4,7 +4,7 @@ import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
import { SpreadFold, SpreadView } from './SpreadView'
import { PeerCursors } from './PeerCursors'
import type { PeerCursor } from './useBookPresence'
import { elementId } from './bookIds'
import { bookElementId as elementId } from '@trek/shared'
import { fontStack } from './bookFonts'
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
import { useStudioStore } from '../../store/studioStore'
@@ -3,7 +3,7 @@ import { Minus, Quote, Search, Square, X } from 'lucide-react'
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, SHAPE_PATHS } from './shapes'
import { GRIDS, defaultGridBox, gridElements } from './grids'
import { BOOK_GRIDS as GRIDS, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared'
import { PanelHead } from './StudioPanelHead'
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
@@ -1,10 +1,11 @@
import type { BookPageSetup } from '@trek/shared'
import { MAX_BOOK_LAYOUTS } from '@trek/shared'
import {
MAX_BOOK_LAYOUTS, bookLayoutPreview as layoutPreview, bookLayoutRole as layoutRole, nextBookLayoutName as nextLayoutName,
} from '@trek/shared'
import { BookmarkPlus, Trash2 } from 'lucide-react'
import { useStudioStore } from '../../store/studioStore'
import { Tooltip } from '../shared/Tooltip'
import { SpreadView } from './SpreadView'
import { layoutPreview, layoutRole, nextLayoutName } from './savedLayouts'
/**
* The book's own layouts, above the built-in ones (#2316).
@@ -11,10 +11,12 @@ import { useToast } from '../shared/Toast'
import { SpreadFold, SpreadView } from './SpreadView'
import { folioLabel, foliosOf } from './bookSheets'
import { photoSrc } from './bookRender'
import { elementId as uid } from './bookIds'
import {
BOOK_COVER_TEMPLATES as COVER_TEMPLATES, BOOK_TEMPLATES as TEMPLATES, applyBookTemplate as applyTemplate,
bookElementId as uid,
} from '@trek/shared'
import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText'
import { coordValue } from './resolveBindings'
import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from './templates'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { PanelHead as Head } from './StudioPanelHead'
import { StudioElementsPanel } from './StudioElementsPanel'
@@ -1,13 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { Check, RouteOff } from 'lucide-react'
import type { BookElement, BookMetric, BookPageSetup, JourneyStats } from '@trek/shared'
import { BOOK_METRICS } from '@trek/shared'
import { BOOK_METRICS, bookElementId as uid } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { PanelHead } from './StudioPanelHead'
import { Section } from './StudioControls'
import { TravelPreview } from './TravelPreview'
import { formatBookCoords } from './entryText'
import { elementId as uid } from './bookIds'
import { routeFor } from './travelRefresh'
import { useMapSources } from './mapSources'
import { fetchRoads } from './roadRoute'
+4 -19
View File
@@ -4,7 +4,7 @@ import type {
import {
MAX_BOOK_COUNTRIES, MAX_BOOK_TITLE, MAX_COUNTRY_NAME, MAX_MAP_POINTS,
MAX_MAP_POINT_LABEL, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, MAX_SPREADS,
MAX_SPREAD_ELEMENTS, MAX_TEXT_LENGTH,
MAX_SPREAD_ELEMENTS, MAX_TEXT_LENGTH, blankBook, blankSpread,
} from '@trek/shared'
import { SPREAD_TEMPLATES } from './bookTemplates.data'
import { applyTemplate, templateFit } from './applyTemplate'
@@ -1581,17 +1581,6 @@ export function buildBook(input: AutoInput): BookDocument {
}
}
/**
* A page with nothing on it, of the given kind.
*
* The first and the last page of a book are laid down empty on purpose: the
* first is where a title page or a dedication goes, the last where a colophon
* does, and neither is something a layout should decide for a person.
*/
function blankSpread(role: BookSpread['role']): BookSpread {
return { id: uid('sp'), role, background: null, elements: [], parked: [], entryId: null }
}
/**
* The page setup for a book that opens on a single page.
*
@@ -1619,15 +1608,11 @@ function withFirstPage(page: BookPageSetup): BookPageSetup {
* The covers are here rather than left out because a book has them and there is
* no other way to add one: the pages rail only inserts inner spreads, between
* the two. The single first and last pages likewise (#2317). Empty, though, in
* the same way the spread between them is.
* the same way the spread between them is. The same blank book the assistant's
* `create_book` writes (shared/src/book/book-pages.ts).
*/
export function emptyBook(input: AutoInput): BookDocument {
return {
version: 1,
title: input.title.slice(0, MAX_BOOK_TITLE),
page: withFirstPage(input.page),
spreads: [blankSpread('cover'), blankSpread('first'), blankSpread('inner'), blankSpread('last'), blankSpread('back')],
}
return blankBook({ title: input.title, page: input.page })
}
/**
-31
View File
@@ -1,31 +0,0 @@
/**
* Ids for the things a book is made of.
*
* Every panel that places an element mints one, and they were four copies of
* the same line: a prefix, `Math.random().toString(36)` and a slice. Copies of
* a line drift, and this one had already drifted in its slice length.
*
* `getRandomValues` rather than `Math.random`, for the reason
* `utils/randomId.ts` gives at greater length: TREK's own quickstart is plain
* http on a LAN address, where `crypto.randomUUID` is undefined but
* `getRandomValues` still works. A book document is shared over a websocket and
* merged by whoever saves last, so two editors minting the same id for two
* different elements is a collision that shows up as one of them losing their
* work. Short of Web Crypto entirely, the old arithmetic is still there.
*
* Seven characters, as before: 36^7 is about 78 billion, against a document
* that holds a few hundred elements.
*/
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
export function elementId(prefix: string): string {
const c = globalThis.crypto
if (c && typeof c.getRandomValues === 'function') {
const bytes = new Uint8Array(7)
c.getRandomValues(bytes)
let out = ''
for (const b of bytes) out += ALPHABET[b % ALPHABET.length]
return `${prefix}-${out}`
}
return `${prefix}-${Math.random().toString(36).slice(2, 9)}`
}
-226
View File
@@ -1,226 +0,0 @@
import type { BookElement, BookPageSetup } from '@trek/shared'
/**
* Photo grids — a set of frames placed together, in one gesture.
*
* ── How this differs from a layout ────────────────────────────────────────
*
* A layout (`templates.ts`) rearranges the *whole spread*: it takes what is
* already there, pours it into a new arrangement and parks the rest. A grid
* adds a block of empty frames wherever you put it, and touches nothing else.
* You reach for a layout when you are deciding what a page is; you reach for a
* grid when you have decided, and want four pictures in a row on the left half.
*
* Canva keeps them apart for the same reason, and calling both "templates"
* would make the difference invisible at exactly the moment it matters.
*
* ── Why fractions ────────────────────────────────────────────────────────
*
* Cells are stored as fractions of the block, so one definition serves a grid
* dropped across a whole spread and the same grid dropped into a quarter of a
* page. The gutter is the exception: it is a real measurement in millimetres,
* because 2mm between two pictures is 2mm whether the block is 60mm or 300mm
* wide — scaling the gap with the block would make small grids look glued
* together and big ones look scattered.
*/
export interface GridCell {
/** Fractions of the block, 0..1. */
x: number
y: number
w: number
h: number
}
export interface GridDef {
id: string
cells: GridCell[]
}
/** `rows` of equal cells, each row split into that many columns. */
function rows(...counts: number[]): GridCell[] {
const h = 1 / counts.length
const out: GridCell[] = []
counts.forEach((cols, r) => {
for (let c = 0; c < cols; c++) out.push({ x: c / cols, y: r * h, w: 1 / cols, h })
})
return out
}
/** `cols` of equal columns, each split into that many rows. */
function cols(...counts: number[]): GridCell[] {
const w = 1 / counts.length
const out: GridCell[] = []
counts.forEach((n, c) => {
for (let r = 0; r < n; r++) out.push({ x: c * w, y: r / n, w, h: 1 / n })
})
return out
}
/**
* The catalogue.
*
* Ordered by how often a photo book actually wants them: one big picture, then
* pairs, then the asymmetric arrangements that carry a page, then the dense
* grids that end a chapter.
*/
export const GRIDS: GridDef[] = [
{ id: 'single', cells: [{ x: 0, y: 0, w: 1, h: 1 }] },
{ id: 'two-across', cells: rows(2) },
{ id: 'two-down', cells: cols(2) },
{ id: 'three-across', cells: rows(3) },
{ id: 'three-down', cells: cols(3) },
// One picture carrying the block, the rest in support. The workhorse of a
// travel page: a landscape wide enough to hold the eye, and the details
// underneath it.
{ id: 'hero-two', cells: [
{ x: 0, y: 0, w: 1, h: 0.62 },
{ x: 0, y: 0.62, w: 0.5, h: 0.38 },
{ x: 0.5, y: 0.62, w: 0.5, h: 0.38 },
] },
{ id: 'hero-three', cells: [
{ x: 0, y: 0, w: 1, h: 0.64 },
...[0, 1, 2].map(i => ({ x: i / 3, y: 0.64, w: 1 / 3, h: 0.36 })),
] },
{ id: 'two-hero', cells: [
{ x: 0, y: 0, w: 0.5, h: 0.36 },
{ x: 0.5, y: 0, w: 0.5, h: 0.36 },
{ x: 0, y: 0.36, w: 1, h: 0.64 },
] },
{ id: 'side-hero', cells: [
{ x: 0, y: 0, w: 0.62, h: 1 },
{ x: 0.62, y: 0, w: 0.38, h: 0.5 },
{ x: 0.62, y: 0.5, w: 0.38, h: 0.5 },
] },
{ id: 'side-hero-left', cells: [
{ x: 0, y: 0, w: 0.38, h: 0.5 },
{ x: 0, y: 0.5, w: 0.38, h: 0.5 },
{ x: 0.38, y: 0, w: 0.62, h: 1 },
] },
{ id: 'side-three', cells: [
{ x: 0, y: 0, w: 0.6, h: 1 },
...[0, 1, 2].map(i => ({ x: 0.6, y: i / 3, w: 0.4, h: 1 / 3 })),
] },
{ id: 'grid-4', cells: rows(2, 2) },
{ id: 'grid-6', cells: rows(3, 3) },
{ id: 'grid-9', cells: rows(3, 3, 3) },
{ id: 'grid-8', cells: rows(4, 4) },
{ id: 'one-two', cells: [
{ x: 0, y: 0, w: 0.5, h: 1 },
{ x: 0.5, y: 0, w: 0.5, h: 0.5 },
{ x: 0.5, y: 0.5, w: 0.5, h: 0.5 },
] },
{ id: 'two-one', cells: [
{ x: 0, y: 0, w: 0.5, h: 0.5 },
{ x: 0, y: 0.5, w: 0.5, h: 0.5 },
{ x: 0.5, y: 0, w: 0.5, h: 1 },
] },
{ id: 'rows-1-2', cells: [
{ x: 0, y: 0, w: 1, h: 0.5 },
{ x: 0, y: 0.5, w: 0.5, h: 0.5 },
{ x: 0.5, y: 0.5, w: 0.5, h: 0.5 },
] },
{ id: 'rows-2-1', cells: [
{ x: 0, y: 0, w: 0.5, h: 0.5 },
{ x: 0.5, y: 0, w: 0.5, h: 0.5 },
{ x: 0, y: 0.5, w: 1, h: 0.5 },
] },
{ id: 'rows-2-3', cells: [...rows(2).map(c => ({ ...c, h: 0.52 })),
...[0, 1, 2].map(i => ({ x: i / 3, y: 0.52, w: 1 / 3, h: 0.48 }))] },
{ id: 'rows-3-2', cells: [...[0, 1, 2].map(i => ({ x: i / 3, y: 0, w: 1 / 3, h: 0.48 })),
...rows(2).map(c => ({ ...c, y: 0.48, h: 0.52 }))] },
{ id: 'cols-1-2', cells: cols(1, 2) },
{ id: 'cols-2-1', cells: cols(2, 1) },
{ id: 'cols-2-1-2', cells: cols(2, 1, 2) },
{ id: 'cols-1-2-1', cells: cols(1, 2, 1) },
// A band across the middle: three pictures at the same height with room above
// and below for words. The arrangement a chapter opener usually wants.
{ id: 'band-three', cells: [0, 1, 2].map(i => ({ x: i / 3, y: 0.26, w: 1 / 3, h: 0.48 })) },
{ id: 'band-four', cells: [0, 1, 2, 3].map(i => ({ x: i / 4, y: 0.3, w: 0.25, h: 0.4 })) },
// Staggered: alternate cells drop by a fraction of their height, which is what
// stops a row of four squares reading as a contact sheet.
{ id: 'stagger-four', cells: [0, 1, 2, 3].map(i => ({
x: i / 4, y: i % 2 ? 0.14 : 0, w: 0.25, h: 0.86,
})) },
{ id: 'stagger-three', cells: [0, 1, 2].map(i => ({
x: i / 3, y: i === 1 ? 0.16 : 0, w: 1 / 3, h: 0.84,
})) },
{ id: 'filmstrip', cells: [0, 1, 2, 3, 4].map(i => ({ x: i / 5, y: 0.32, w: 0.2, h: 0.36 })) },
{ id: 'grid-12', cells: rows(4, 4, 4) },
]
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
/**
* Turn a grid into elements, inside a box on the page.
*
* The gutter is taken out of each cell rather than added between them, so the
* block occupies exactly the box it was given — a grid asked to fill the safe
* area fills the safe area, instead of overhanging it by one gutter.
*/
export function gridElements(
grid: GridDef,
box: { x: number; y: number; w: number; h: number },
gutter = 3,
radius = 0,
): BookElement[] {
const half = gutter / 2
return grid.cells.map(cell => {
const x = box.x + cell.x * box.w
const y = box.y + cell.y * box.h
const w = cell.w * box.w
const h = cell.h * box.h
// Only interior edges get half a gutter; the block's outer edge is the box.
const left = cell.x > 0.001 ? half : 0
const top = cell.y > 0.001 ? half : 0
const right = cell.x + cell.w < 0.999 ? half : 0
const bottom = cell.y + cell.h < 0.999 ? half : 0
return {
id: uid('p'),
kind: 'photo',
frame: {
x: x + left,
y: y + top,
w: Math.max(2, w - left - right),
h: Math.max(2, h - top - bottom),
},
rotation: 0,
opacity: 1,
locked: false,
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius,
filter: 'none',
mask: null,
frameStyle: 'none',
} as BookElement
})
}
/**
* The box a grid lands in when it is simply clicked rather than placed.
*
* The safe area of the page you are looking at, which is almost always what was
* meant — a grid that lands half in the gutter has to be dragged before it is
* any use.
*/
export function defaultGridBox(page: BookPageSetup, single: boolean): { x: number; y: number; w: number; h: number } {
const W = single ? page.pageWidth : page.pageWidth * 2
const margin = Math.max(page.safe, Math.min(W, page.pageHeight) * 0.075)
return {
x: margin,
y: margin,
w: W - margin * 2,
h: page.pageHeight - margin * 2,
}
}
@@ -1,131 +0,0 @@
import type { BookElement, BookLayout, BookPageSetup, BookSpread } from '@trek/shared'
import { MAX_SPREAD_ELEMENTS } from '@trek/shared'
import { elementId } from './bookIds'
/**
* A book's own layouts (#2316): a page somebody arranged, kept to lay other
* pages out the same way.
*
* The built-in layouts in templates.ts are frames only. A saved one is the whole
* design, panels and shapes and type included, with the pictures taken out; the
* page it is applied to brings its own.
*/
/** Which saved layouts fit a spread: a double spread's design does not fit a single page. */
export const layoutRole = (spread: BookSpread): BookLayout['role'] => (spread.role === 'inner' ? 'inner' : 'single')
/** The arrangement of a spread, as a layout to keep. */
export function layoutFromSpread(spread: BookSpread, page: BookPageSetup, name: string): BookLayout {
return {
id: elementId('ly'),
name,
role: layoutRole(spread),
pageWidth: page.pageWidth,
pageHeight: page.pageHeight,
background: spread.background,
// The design, not the pictures: every frame comes out empty.
elements: spread.elements.map(el => (el.kind === 'photo' ? { ...el, photoId: null } : el)),
}
}
/** The next free "Layout n" name, so a save needs no dialog. */
export function nextLayoutName(existing: readonly BookLayout[], base: string): string {
const taken = new Set(existing.map(l => l.name))
let n = existing.length + 1
while (taken.has(`${base} ${n}`)) n += 1
return `${base} ${n}`
}
const textSize = (el: BookElement) => (el.kind === 'text' ? el.size : 0)
/**
* Lay a spread out on a saved layout, keeping what the spread holds.
*
* The same contract as applying a built-in layout: the spread's pictures go into
* the layout's frames in order, its words into the layout's text boxes (the
* largest into the largest, so the title stays the title), and whatever does not
* fit is parked rather than lost. The layout's own type keeps its look and takes
* the spread's words; a text box that was filled from a journal entry and finds
* nothing to take is left out, because its words belonged to the page it was
* saved from. The small marks and the pros and cons list are content too: each
* takes the values of the spread's own mark of the same kind, in order (the
* spread's day, its coordinates), and keeps the layout's look. Everything else
* is the design and is copied as drawn, scaled from the page it was drawn on to
* this one.
*/
export function applySavedLayout(spread: BookSpread, layout: BookLayout, page: BookPageSetup): BookSpread {
const sx = page.pageWidth / layout.pageWidth
const sy = page.pageHeight / layout.pageHeight
const pool = [...spread.elements, ...(spread.parked ?? [])]
const photos = pool.filter(e => e.kind === 'photo' && e.photoId != null)
const texts = pool.filter(e => e.kind === 'text' && e.text.trim()).sort((a, b) => textSize(b) - textSize(a))
// Which of the layout's text boxes takes which of the spread's words.
const slots = layout.elements.filter(e => e.kind === 'text').sort((a, b) => textSize(b) - textSize(a))
const wordsFor = new Map<BookElement, BookElement>()
slots.forEach((slot, i) => { if (texts[i]) wordsFor.set(slot, texts[i]) })
// The spread's own marks and lists, queued per kind, for the layout's to take their values from.
const marks = new Map<string, BookElement[]>()
for (const el of spread.elements) {
const key = el.kind === 'badge' ? `badge:${el.variant}` : el.kind === 'list' ? 'list' : null
if (key) marks.set(key, [...(marks.get(key) ?? []), el])
}
const takeMark = (key: string) => marks.get(key)?.shift()
let pi = 0
const out: BookElement[] = []
for (const el of layout.elements) {
const frame = { x: el.frame.x * sx, y: el.frame.y * sy, w: el.frame.w * sx, h: el.frame.h * sy }
const base = { ...el, id: elementId(el.kind[0]), frame } as BookElement
if (base.kind === 'photo') {
const src = photos[pi++]
out.push(src && src.kind === 'photo'
? { ...base, photoId: src.photoId, focalX: src.focalX, focalY: src.focalY }
: base)
continue
}
if (base.kind === 'text' && el.kind === 'text') {
const src = wordsFor.get(el)
if (src && src.kind === 'text') {
out.push({ ...base, text: src.text, binding: src.binding, overridden: src.overridden })
} else if (!el.binding) {
out.push(base)
}
continue
}
if (base.kind === 'badge') {
const own = takeMark(`badge:${base.variant}`)
out.push(own && own.kind === 'badge' ? { ...base, text: own.text, sub: own.sub, code: own.code } : base)
continue
}
if (base.kind === 'list') {
const own = takeMark('list')
out.push(own && own.kind === 'list' ? { ...base, items: own.items } : base)
continue
}
out.push(base)
}
const used = new Set(wordsFor.values())
const parked = [
...photos.slice(pi),
...texts.filter(t => !used.has(t)),
].slice(0, MAX_SPREAD_ELEMENTS)
return { ...spread, background: layout.background, elements: out, parked }
}
/** A saved layout drawn as a spread on this book's page, for its card. Nothing is filled in. */
export function layoutPreview(layout: BookLayout, page: BookPageSetup): BookSpread {
const sx = page.pageWidth / layout.pageWidth
const sy = page.pageHeight / layout.pageHeight
return {
id: layout.id,
role: layout.role === 'inner' ? 'inner' : 'first',
background: layout.background,
elements: layout.elements.map(el => ({ ...el, frame: { x: el.frame.x * sx, y: el.frame.y * sy, w: el.frame.w * sx, h: el.frame.h * sy } }) as BookElement),
parked: [],
entryId: null,
}
}
@@ -1,5 +1,5 @@
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
import { elementId } from './bookIds'
import { bookElementId as elementId } from '@trek/shared'
/**
* What was copied, and from where (#2316).
-447
View File
@@ -1,447 +0,0 @@
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
/**
* Layout templates for a spread.
*
* 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 Template {
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) => TemplateSlot[]
}
export type TemplateSlot =
| { 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. `COVER_TEMPLATES` holds the single-page set. */
export const TEMPLATES: Template[] = [
{
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 COVER_TEMPLATES: Template[] = [
{
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 } },
]
},
},
]
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
const HEADING = { size: 22, weight: 700 as const, leading: 1.1, tracking: -0.02, color: '#141414' }
const META = { size: 7.5, weight: 600 as const, tracking: 0.14, color: '#8a8578' }
const BODY = { size: 10, weight: 400 as const, leading: 1.6, color: '#2a2a2a' }
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: uid('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
}
/**
* 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 applyTemplate(spread: BookSpread, tpl: Template, 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')
/*
* Which text is the heading, which the story, which the 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.
*/
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
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: uid('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 }
}
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router'
import type { BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { bookElementId as elementId, bookPageSetupSchema } from '@trek/shared'
import { journeyApi } from '../../api/client'
import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore'
import { useStudioStore } from '../../store/studioStore'
import { useToast } from '../../components/shared/Toast'
import { elementId } from '../../components/Studio/bookIds'
import { uploadStudioPhotos, type StudioUploader } from '../../components/Studio/studioUpload'
import { useBookStore } from '../../components/Studio/useBookStore'
import { useBookPresence } from '../../components/Studio/useBookPresence'
+8 -33
View File
@@ -1,8 +1,10 @@
import { create } from 'zustand'
import type { BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared'
import { MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS } from '@trek/shared'
import {
MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS, applySavedBookLayout as applySavedLayout,
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, innerSpreadBounds as innerBounds,
} from '@trek/shared'
import { pastedElements, type StudioClipboard } from '../components/Studio/studioClipboard'
import { applySavedLayout, layoutFromSpread } from '../components/Studio/savedLayouts'
/**
* The book being edited.
@@ -92,33 +94,9 @@ interface StudioState {
canRedo: () => boolean
}
/**
* Where an inner spread may go: after the cover and the first page, before
* the last page and the back cover.
*
* Read from the document each time rather than assumed, because a book from
* before the single first page existed has none, and a book with no covers at
* all (the tests build those) has nothing to stay inside of. The lower bound
* is not decoration either: a book with no inner spreads yet reports its last
* inner one as -1, which asked for a page at 0 — in front of the cover, where
* it could not be moved back from, because a move only ever swaps with
* another inner spread.
*/
function innerBounds(spreads: readonly BookSpread[]): { first: number; limit: number } {
let first = 0
for (let i = 0; i < spreads.length; i++) {
const role = spreads[i].role
if (role === 'cover' || role === 'first') first = i + 1
else break
}
let limit = spreads.length
for (let i = spreads.length - 1; i >= 0; i--) {
const role = spreads[i].role
if (role === 'back' || role === 'last') limit = i
else break
}
return { first, limit: Math.max(first, limit) }
}
// Where an inner spread may go (`innerBounds`) and which spreads may move at
// all (`isMovableSpread`) are shared with the server's book tools
// (shared/src/book/book-pages.ts), so the assistant keeps the same pages fixed.
function replaceSpread(doc: BookDocument, index: number, fn: (s: BookSpread) => BookSpread): BookDocument {
const spreads = doc.spreads.slice()
@@ -289,10 +267,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
* rail, the menu and the store all ask it rather than each testing `role`
* for themselves.
*/
canEditSpread: index => {
const sp = get().doc?.spreads[index]
return !!sp && sp.role === 'inner'
},
canEditSpread: index => isMovableSpread(get().doc?.spreads[index]),
addSpread: index => {
const doc = get().doc
-178
View File
@@ -1,178 +0,0 @@
import { describe, it, expect } from 'vitest'
import type { BookPageSetup } from '@trek/shared'
import { GRIDS, defaultGridBox, gridElements } from '../../../src/components/Studio/grids'
import { bookPageSetupSchema } from '@trek/shared'
/**
* Photo grids (#1973).
*
* The rules worth pinning are geometric: a grid must tile the box it is given
* without gaps or overlaps, and the block it produces must occupy exactly that
* box — a grid asked to fill the safe area that overhangs it by one gutter is
* a grid that has to be dragged before it is any use.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({
preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
})
const box = { x: 0, y: 0, w: 100, h: 100 }
describe('the grid catalogue', () => {
it('gives every grid a unique id', () => {
const ids = GRIDS.map(g => g.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('gives every grid at least one cell', () => {
for (const grid of GRIDS) expect(grid.cells.length, grid.id).toBeGreaterThan(0)
})
it('keeps every cell inside the unit block', () => {
for (const grid of GRIDS) {
for (const cell of grid.cells) {
expect(cell.x, grid.id).toBeGreaterThanOrEqual(0)
expect(cell.y, grid.id).toBeGreaterThanOrEqual(0)
expect(cell.x + cell.w, grid.id).toBeLessThanOrEqual(1.0001)
expect(cell.y + cell.h, grid.id).toBeLessThanOrEqual(1.0001)
expect(cell.w, grid.id).toBeGreaterThan(0)
expect(cell.h, grid.id).toBeGreaterThan(0)
}
}
})
/*
* Tiling grids cover the block exactly. The staggered and band arrangements
* deliberately do not — they are compositions with air around them — so they
* are checked for "no overlap" only, further down.
*/
it('tiles the block completely, for the grids that are meant to', () => {
const tiling = GRIDS.filter(g => !/stagger|band|filmstrip/.test(g.id))
for (const grid of tiling) {
const area = grid.cells.reduce((sum, c) => sum + c.w * c.h, 0)
expect(area, grid.id).toBeCloseTo(1, 5)
}
})
it('never overlaps two cells', () => {
for (const grid of GRIDS) {
for (let i = 0; i < grid.cells.length; i++) {
for (let j = i + 1; j < grid.cells.length; j++) {
const a = grid.cells[i]
const b = grid.cells[j]
const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
const overlaps = overlapX > 0.0001 && overlapY > 0.0001
expect(overlaps, `${grid.id}: cell ${i} and ${j}`).toBe(false)
}
}
}
})
})
describe('gridElements', () => {
it('makes one empty photo frame per cell', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 0)
expect(els).toHaveLength(4)
for (const el of els) {
expect(el.kind).toBe('photo')
expect(el.kind === 'photo' && el.photoId).toBeNull()
}
})
it('gives every frame its own id', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-9')!, box, 0)
expect(new Set(els.map(e => e.id)).size).toBe(els.length)
})
it('fills the box exactly when there is no gutter', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 0)
const left = Math.min(...els.map(e => e.frame.x))
const top = Math.min(...els.map(e => e.frame.y))
const right = Math.max(...els.map(e => e.frame.x + e.frame.w))
const bottom = Math.max(...els.map(e => e.frame.y + e.frame.h))
expect(left).toBeCloseTo(0, 6)
expect(top).toBeCloseTo(0, 6)
expect(right).toBeCloseTo(100, 6)
expect(bottom).toBeCloseTo(100, 6)
})
/*
* The gutter comes out of the cells, not out of the block. Added between them
* instead, a four-across grid would be three gutters wider than the box it
* was told to fill, and would hang off the safe area it was aligned to.
*/
it('keeps the block inside its box once a gutter is applied', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-6')!, box, 4)
for (const el of els) {
expect(el.frame.x).toBeGreaterThanOrEqual(-0.001)
expect(el.frame.y).toBeGreaterThanOrEqual(-0.001)
expect(el.frame.x + el.frame.w).toBeLessThanOrEqual(100.001)
expect(el.frame.y + el.frame.h).toBeLessThanOrEqual(100.001)
}
})
it('leaves the block edges flush and only splits the interior gaps', () => {
const gutter = 6
const els = gridElements(GRIDS.find(g => g.id === 'two-across')!, box, gutter)
const [a, b] = [...els].sort((x, y) => x.frame.x - y.frame.x)
// Outer edges untouched...
expect(a.frame.x).toBeCloseTo(0, 6)
expect(b.frame.x + b.frame.w).toBeCloseTo(100, 6)
// ...and exactly one gutter between them.
expect(b.frame.x - (a.frame.x + a.frame.w)).toBeCloseTo(gutter, 6)
})
it('offsets the whole block when the box does not start at the origin', () => {
const els = gridElements(GRIDS.find(g => g.id === 'single')!, { x: 20, y: 30, w: 50, h: 40 }, 0)
expect(els[0].frame).toMatchObject({ x: 20, y: 30, w: 50, h: 40 })
})
it('never produces a frame too small to see, even from a tiny box', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-12')!, { x: 0, y: 0, w: 8, h: 8 }, 6)
for (const el of els) {
expect(el.frame.w).toBeGreaterThanOrEqual(2)
expect(el.frame.h).toBeGreaterThanOrEqual(2)
}
})
it('carries the corner radius onto every frame', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 3, 5)
for (const el of els) expect(el.kind === 'photo' && el.radius).toBe(5)
})
it('produces frames for every grid in the catalogue without a bad number', () => {
for (const grid of GRIDS) {
for (const el of gridElements(grid, box, 3)) {
for (const v of [el.frame.x, el.frame.y, el.frame.w, el.frame.h]) {
expect(Number.isFinite(v), grid.id).toBe(true)
}
}
}
})
})
describe('defaultGridBox', () => {
it('spans both pages of a spread', () => {
const b = defaultGridBox(page, false)
expect(b.x + b.w).toBeCloseTo(page.pageWidth * 2 - b.x, 6)
expect(b.w).toBeGreaterThan(page.pageWidth)
})
it('stays on one page for a cover', () => {
const b = defaultGridBox(page, true)
expect(b.x + b.w).toBeLessThanOrEqual(page.pageWidth)
})
it('keeps clear of the safe area rather than landing on it', () => {
const b = defaultGridBox(page, true)
expect(b.x).toBeGreaterThanOrEqual(page.safe)
expect(b.y).toBeGreaterThanOrEqual(page.safe)
})
it('is symmetric, so a grid dropped into it looks centred', () => {
const b = defaultGridBox(page, false)
expect(b.x).toBeCloseTo(page.pageWidth * 2 - (b.x + b.w), 6)
expect(b.y).toBeCloseTo(page.pageHeight - (b.y + b.h), 6)
})
})
@@ -0,0 +1,20 @@
/**
* PRESETS-001
*
* The format sizes live in two places: the Studio's picker here, and shared,
* where the assistant's create_book lays a new book down. This holds them to
* the same millimetres, so a book made either way has the same trim.
*/
import { BOOK_PAGE_PRESET_SIZES } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { PAGE_PRESETS } from '../../../src/components/Studio/pagePresets';
describe('page format sizes', () => {
it('PRESETS-001: match the shared table a new book is laid down with, format for format', () => {
const fromStudio: Record<string, object> = {};
for (const p of Object.values(PAGE_PRESETS)) {
fromStudio[p.id] = { pageWidth: p.pageWidthMm, pageHeight: p.pageHeightMm, bleed: p.bleedMm, safe: p.safeMm };
}
expect(BOOK_PAGE_PRESET_SIZES).toEqual(fromStudio);
});
});
@@ -3,11 +3,10 @@ import type { BookDocument, BookElement, BookPageSetup, BookSpread } from '@trek
import {
MAX_BOOK_LAYOUTS, bookBadgeElementSchema, bookLayoutSchema, bookPageSetupSchema, bookPhotoElementSchema,
bookShapeElementSchema, bookTextElementSchema,
applySavedBookLayout as applySavedLayout, bookLayoutFromSpread as layoutFromSpread,
bookLayoutPreview as layoutPreview, bookLayoutRole as layoutRole, nextBookLayoutName as nextLayoutName,
} from '@trek/shared'
import { pastedElements } from '../../../src/components/Studio/studioClipboard'
import {
applySavedLayout, layoutFromSpread, layoutPreview, layoutRole, nextLayoutName,
} from '../../../src/components/Studio/savedLayouts'
import { useStudioStore } from '../../../src/store/studioStore'
/**
-150
View File
@@ -1,150 +0,0 @@
import { describe, it, expect } from 'vitest'
import type { BookPageSetup } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from '../../../src/components/Studio/templates'
/**
* Layouts (#1973).
*
* The cover set exists because a cover is a different problem from a spread:
* one page, type as the subject rather than as a caption. A spread layout
* applied to it lands half its frames past the edge of the page, which is why
* the two sets are kept apart rather than filtered out of one.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({
preset: 'square-210', pageWidth: 210, pageHeight: 210,
})
describe('the layout catalogue', () => {
it('gives every layout a unique id across both sets', () => {
const ids = [...TEMPLATES, ...COVER_TEMPLATES].map(t => t.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('gives every layout at least one slot', () => {
for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) {
expect(tpl.build(page).length, tpl.id).toBeGreaterThan(0)
}
})
it('counts its photo slots honestly', () => {
for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) {
const drawn = tpl.build(page).filter(s => s.kind === 'photo').length
expect(drawn, tpl.id).toBe(tpl.photoSlots)
}
})
it('gives every slot a frame with a positive size', () => {
for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) {
for (const slot of tpl.build(page)) {
expect(slot.frame.w, tpl.id).toBeGreaterThan(0)
expect(slot.frame.h, tpl.id).toBeGreaterThan(0)
}
}
})
})
describe('the cover set', () => {
it('is marked as single-page throughout', () => {
for (const tpl of COVER_TEMPLATES) {
expect(tpl.role, tpl.id).toBe('single')
}
})
/*
* The rule that makes them a separate set: everything stays on one page.
* A frame reaching past pageWidth would be half in the fold of a book that
* has no fold there.
*/
it('keeps every frame on the one page', () => {
for (const tpl of COVER_TEMPLATES) {
for (const slot of tpl.build(page)) {
expect(slot.frame.x, `${tpl.id} x`).toBeGreaterThanOrEqual(-page.bleed - 0.01)
expect(slot.frame.x + slot.frame.w, `${tpl.id} right`)
.toBeLessThanOrEqual(page.pageWidth + page.bleed + 0.01)
expect(slot.frame.y + slot.frame.h, `${tpl.id} bottom`)
.toBeLessThanOrEqual(page.pageHeight + page.bleed + 0.01)
}
}
})
it('always has somewhere for the title', () => {
for (const tpl of COVER_TEMPLATES) {
expect(tpl.build(page).some(s => s.kind === 'heading'), tpl.id).toBe(true)
}
})
it('includes one that needs no photograph at all', () => {
expect(COVER_TEMPLATES.some(t => t.photoSlots === 0)).toBe(true)
})
})
describe('the spread set', () => {
it('is not marked single, so the panel keeps the two apart', () => {
for (const tpl of TEMPLATES) {
expect(tpl.role ?? 'inner', tpl.id).toBe('inner')
}
})
it('stays within the spread', () => {
for (const tpl of TEMPLATES) {
for (const slot of tpl.build(page)) {
expect(slot.frame.x + slot.frame.w, `${tpl.id} right`)
.toBeLessThanOrEqual(page.pageWidth * 2 + page.bleed + 0.01)
}
}
})
it('offers a range of photo counts rather than five versions of one idea', () => {
const counts = new Set(TEMPLATES.map(t => t.photoSlots))
expect(counts.size).toBeGreaterThanOrEqual(6)
})
})
describe('applying a cover layout', () => {
const spread = {
id: 'c1', role: 'cover' as const, background: null, parked: [], entryId: null,
elements: [
{
id: 'p1', kind: 'photo' as const, frame: { x: 0, y: 0, w: 50, h: 50 },
rotation: 0, opacity: 1, locked: false, photoId: 42, fit: 'cover' as const,
focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none' as const,
mask: null, frameStyle: 'none' as const,
},
{
id: 't1', kind: 'text' as const, frame: { x: 0, y: 60, w: 100, h: 20 },
rotation: 0, opacity: 1, locked: false, text: 'Iceland', font: 'sans' as const,
size: 30, weight: 700 as const, italic: false, align: 'left' as const,
leading: 1.1, tracking: 0, color: '#111111', binding: null, overridden: false,
},
],
}
it('pours the existing picture and title into the new arrangement', () => {
const next = applyTemplate(spread, COVER_TEMPLATES[0], page)
const photo = next.elements.find(e => e.kind === 'photo')
const text = next.elements.find(e => e.kind === 'text')
expect(photo?.kind === 'photo' && photo.photoId).toBe(42)
expect(text?.kind === 'text' && text.text).toBe('Iceland')
})
/*
* Trying the type-only cover must not cost the photograph — it comes back
* the moment a layout with a frame is applied.
*/
it('parks what a type-only cover has no room for', () => {
const quiet = COVER_TEMPLATES.find(t => t.photoSlots === 0)!
const next = applyTemplate(spread, quiet, page)
expect(next.elements.some(e => e.kind === 'photo')).toBe(false)
expect(next.parked.some(e => e.kind === 'photo' && e.photoId === 42)).toBe(true)
})
it('brings the parked picture back on a layout that has room', () => {
const quiet = COVER_TEMPLATES.find(t => t.photoSlots === 0)!
const parked = applyTemplate(spread, quiet, page)
const back = applyTemplate(parked, COVER_TEMPLATES[0], page)
const photo = back.elements.find(e => e.kind === 'photo')
expect(photo?.kind === 'photo' && photo.photoId).toBe(42)
})
})