Files
TREK/client/src/components/Studio/applyTemplate.ts
T
Maurice 19745b7b0d fix(studio): keep "Keep mine" from throwing the document away
After a save conflict, Keep mine set the version and unblocked writes but
never re-queued the local document, and write() had already cleared the
queue before the request. So if the user picked Keep mine and then simply
closed the editor without typing anything else, saveNow() found nothing
pending and did nothing: their afternoon of edits existed only in memory,
the indicator said idle, and the server kept the other version. The exact
outcome the conflict dialog promises to prevent.

Ctrl+Z was intercepted while typing in a text field too. Studio commits text
on blur, so mid-edit the typing is not in the undo history yet: the browser's
own text undo was suppressed and a previous layout change was undone
underneath the open editor instead. Reads as random data loss.

Also: delete no longer removes locked elements, the auto-layout no longer
freezes an empty GPS path when tracks resolve after the one-shot build, the
back-cover tally cannot print "0 places", the map fetch aborts when Studio
closes, and a double Enter in the list-name field stops creating two lists.

The page-pattern gate had quietly stopped covering anything: the Desktop-shim
refactor moved every real container out of the default export the checker
reads, and its regex missed useRef<T>(null) anyway. Widened on both counts,
and the state it immediately found moved into the hooks.
2026-08-23 11:59:12 +02:00

238 lines
9.0 KiB
TypeScript

import type { BookElement, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
import type { SpreadTemplate } from './bookTemplates.data'
import { formatBookCoords, formatBookDate } from './entryText'
import { coordValue } from './resolveBindings'
/**
* Laying an entry out on a template somebody drew.
*
* ── Why this exists ──────────────────────────────────────────────────────
*
* The layouts written as code in autoLayout.ts are what a function can reason
* its way to: a picture here, words there, margins that add up. What they are
* not is *designed* — a panel bled off one edge, a shape running off the
* corner, a strip of pictures at two sizes with the coordinates set under them.
* Those decisions come from looking at a page, and the editor is where looking
* at a page happens.
*
* So the templates are drawn in Studio and read back out (see
* bookTemplates.data.ts). This is the other half: taking one of those spreads
* and putting an entry into it.
*
* ── What it fills, and what it leaves alone ──────────────────────────────
*
* Only what the entry can answer for:
*
* - text carrying a `binding` — the title, the story, a caption
* - empty photo frames, taking the entry's photographs in order
* - the day, coordinate, date and country marks
* - the stats and countries elements, from the journey's figures
*
* Everything else is the design and is copied as drawn. A panel stays where it
* was put; a decorative shape keeps its colour. That division is the whole
* contract between the person who draws a template and the code that uses it.
*
* ── Sizes ────────────────────────────────────────────────────────────────
*
* The template's numbers are fractions of the page it was drawn on, so they are
* multiplied back up here. A template built on a 210mm square lays out on an A4
* landscape book without being redrawn.
*/
export interface TemplateEntry {
id: number
title: string | null
story: string | null
location: string | null
date: string | null
photos: { photoId: number; width: number | null; height: number | null; caption?: string | null }[]
lat?: number | null
lng?: number | null
country?: string | null
dayNumber?: number | null
dayCount?: number | null
}
export interface TemplateContext {
page: BookPageSetup
locale: string
stats: JourneyStats | null
/** Already translated, because this module has no translation context. */
dayLabel: string
}
let seq = 0
const uid = (p: string) => `${p}-${(seq++).toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`
/** How well a template suits an entry: more is better, -1 means unusable. */
export function templateFit(template: SpreadTemplate, entry: TemplateEntry): number {
const frames = template.elements.filter(e => e.kind === 'photo').length
const photos = entry.photos.length
const wantsStory = template.elements.some(e => e.kind === 'text' && e.binding?.source === 'entry.story')
const hasStory = !!(entry.story || '').trim()
/*
* A template is a template *for an entry* when it uses one.
*
* The distinction has to come out of the drawing rather than from a label
* somebody remembers to set: a summary spread is all figures and country
* outlines and reads perfectly well on its own, and dropping a day's
* photographs into it would be nonsense. Bindings are how a page says it
* expects an entry.
*/
const usesEntry = template.elements.some(
e => ('binding' in e && e.binding) || (e.kind === 'photo' && e.photoId == null),
)
if (!usesEntry) return -1
/*
* A template with three frames and an entry with one picture leaves two
* placeholders on a finished page, which is worse than a simpler layout.
* More pictures than frames is fine — the extra ones are parked.
*/
if (frames > photos + 1) return -1
if (wantsStory && !hasStory) return -1
// Closest match wins: a two-frame template for two pictures beats a one-frame
// one, and both beat a template that would leave a hole.
return 100 - Math.abs(frames - photos) * 10 - (wantsStory === hasStory ? 0 : 5)
}
/** Fill a template with an entry, and hand back the spread it makes. */
export function applyTemplate(
template: SpreadTemplate,
entry: TemplateEntry,
ctx: TemplateContext,
): BookSpread {
const { page } = ctx
const PW = page.pageWidth
const PH = page.pageHeight
let nextPhoto = 0
const story = (entry.story || '').trim()
const heading = entry.title || entry.location || ''
const elements = template.elements.map(el => {
const out = {
...el,
id: uid(el.kind[0]),
frame: {
x: round2(el.frame.x * PW),
y: round2(el.frame.y * PH),
w: round2(el.frame.w * PW),
h: round2(el.frame.h * PH),
},
} as BookElement
// Sizes came out as fractions of the page height, so they go back up.
if ('size' in out && typeof out.size === 'number') out.size = round2(out.size * PH)
if ('radius' in out && typeof out.radius === 'number') out.radius = round2(out.radius * PW)
if ('strokeWidth' in out && typeof out.strokeWidth === 'number') {
out.strokeWidth = round2(out.strokeWidth * PW)
}
if (out.kind === 'text' && out.binding) {
const source = out.binding.source
const format = out.binding.format
// What the words were made from, where they were made rather than copied.
let value: string | undefined
if (source === 'entry.title') out.text = heading
else if (source === 'entry.story') out.text = story
else if (source === 'entry.date') {
out.text = formatBookDate(entry.date, ctx.locale)
value = entry.date ?? undefined
}
// The place, as its name or as its point — see the `format` note in the
// contract. A stop the entry cannot answer keeps the words the template
// was drawn with, the same bargain fillBadge makes below.
else if (source === 'entry.location') {
if (format && entry.lat != null && entry.lng != null) {
out.text = formatBookCoords(entry.lat, entry.lng, format)
value = coordValue(entry.lat, entry.lng)
} else if (!format && entry.location) {
out.text = entry.location
}
}
out.binding = { ...out.binding, entryId: entry.id, ...(value ? { value } : {}) }
}
if (out.kind === 'photo' && out.photoId == null) {
const photo = entry.photos[nextPhoto++]
if (photo) out.photoId = photo.photoId
}
if (out.kind === 'badge') fillBadge(out, entry, ctx)
if (out.kind === 'countries' && ctx.stats) {
out.codes = ctx.stats.countries.map(c => c.code)
out.names = ctx.stats.countries.map(c => c.name)
}
// The figures live in one map on the element, keyed by metric — the same
// shape the panels write when a stats element is placed by hand.
if (out.kind === 'stats' && ctx.stats) {
out.values = {
distance: ctx.stats.distance,
days: ctx.stats.days,
steps: ctx.stats.steps,
photos: ctx.stats.photos,
places: ctx.stats.places,
countries: ctx.stats.countries.length,
furthest: ctx.stats.furthest,
}
}
return out
})
return {
id: uid('sp'),
role: 'inner',
background: template.background,
elements,
parked: [],
entryId: entry.id,
}
}
/**
* The small marks, filled from the stop.
*
* A badge whose value the entry cannot answer keeps the one it was drawn with:
* a template showing "DAY 1" on an entry with no date is a template that reads
* as a placeholder, which is exactly what it is until a date exists.
*/
function fillBadge(el: BookElement & { kind: 'badge' }, entry: TemplateEntry, ctx: TemplateContext) {
if (el.variant === 'day' && entry.dayNumber) {
el.text = `${ctx.dayLabel} ${entry.dayNumber}`
}
if (el.variant === 'coords' && entry.lat != null && entry.lng != null) {
el.text = formatBookCoords(entry.lat, entry.lng)
}
if ((el.variant === 'flag' || el.variant === 'country') && entry.country) {
el.code = entry.country
el.text = countryName(entry.country, ctx).toUpperCase()
}
if (el.variant === 'date' && entry.date) {
const d = new Date(`${entry.date}T00:00:00`)
if (!Number.isNaN(d.getTime())) {
el.text = String(d.getDate())
el.sub = d.toLocaleDateString(ctx.locale, { month: 'long' }).toUpperCase()
}
}
if (el.variant === 'distance' && ctx.stats?.distance) {
el.text = `${Math.round(ctx.stats.distance / 1000).toLocaleString(ctx.locale)} km`
}
}
function countryName(code: string, ctx: TemplateContext): string {
const known = ctx.stats?.countries.find(c => c.code === code)
try {
return new Intl.DisplayNames([ctx.locale], { type: 'region' }).of(code) ?? known?.name ?? code
} catch {
return known?.name ?? code
}
}
const round2 = (n: number) => Math.round(n * 100) / 100