mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(studio): the book engine in shared: layouts, grids, page ops and findings
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user