diff --git a/client/package.json b/client/package.json index f406faf07..7c9e00253 100644 --- a/client/package.json +++ b/client/package.json @@ -29,8 +29,13 @@ "format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"" }, "dependencies": { + "@fontsource/bebas-neue": "^5.3.0", + "@fontsource/eb-garamond": "^5.3.0", "@fontsource/geist-sans": "^5.2.5", + "@fontsource/inter": "^5.3.0", + "@fontsource/lora": "^5.3.0", "@fontsource/museomoderno": "^5.3.0", + "@fontsource/playfair-display": "^5.3.0", "@fontsource/poppins": "^5.2.7", "@simplewebauthn/browser": "^13.1.2", "@trek/shared": "*", diff --git a/client/scripts/build-country-shapes.mjs b/client/scripts/build-country-shapes.mjs new file mode 100644 index 000000000..7cbbae3e6 --- /dev/null +++ b/client/scripts/build-country-shapes.mjs @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * Country silhouettes for TREK Studio, built from the boundaries the server + * already bundles for Atlas. + * + * node scripts/build-country-shapes.mjs + * + * Writes `src/components/Studio/countryShapes.ts`. Run it when + * `server/assets/atlas/admin0.geojson.gz` changes; the output is committed, so + * a normal build never runs this. + * + * ── Why a second, tiny copy of the boundaries ───────────────────────────── + * + * Atlas's admin-0 file is 4.3MB gzipped and ~30MB parsed, sized for a slippy + * map you can zoom into. A book page draws a country at 20mm across, where the + * ten thousand points describing a fjord land inside one printer dot. Shipping + * the map data to draw a stamp would cost more than the entire Studio bundle. + * + * So: project, drop the islands nobody can see at that size, simplify hard, and + * normalise each country into its own 0..100 box. What comes out is a few + * hundred bytes per country and prints identically, because at 20mm it *is* + * identical. + * + * ── The three things that are easy to get wrong ─────────────────────────── + * + * 1. **Project before simplifying.** Simplifying in degrees and projecting + * afterwards removes the wrong points: a degree of longitude is 111km at the + * equator and 30km at Reykjavík, so a fixed tolerance eats Iceland's coast + * while leaving Kenya's untouched. + * 2. **Countries that cross the antimeridian.** Russia and Fiji have parts at + * +179 and -179. Taken literally their bounding box is the whole world and + * the silhouette is a horizontal smear. Rings that span more than half the + * globe get their negative longitudes lifted by 360 so the country is + * contiguous again. + * 3. **Islands.** Keeping every ring turns Norway into a dust cloud and Greece + * into static. Rings below a fraction of the largest one are dropped — the + * mainland is what makes a country recognisable at a glance. + */ + +import fs from 'node:fs' +import path from 'node:path' +import url from 'node:url' +import zlib from 'node:zlib' + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)) +const SRC = path.join(HERE, '..', '..', 'server', 'assets', 'atlas', 'admin0.geojson.gz') +const OUT = path.join(HERE, '..', 'src', 'components', 'Studio', 'countryShapes.ts') + +/** Rings smaller than this share of the biggest one are not drawn. */ +const ISLAND_CUTOFF = 0.012 +/** Never draw more than this many rings for one country. */ +const MAX_RINGS = 14 +/** Point budget per country, across every ring it keeps. */ +const MAX_POINTS = 190 + +/** Web Mercator, unscaled. y is flipped so it grows downwards like SVG's. */ +function project([lng, lat]) { + const clamped = Math.max(-85.05, Math.min(85.05, lat)) + const rad = (clamped * Math.PI) / 180 + return [lng, -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI] +} + +function ringArea(ring) { + let a = 0 + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1] + } + return Math.abs(a / 2) +} + +/** Perpendicular distance from p to the segment ab, squared. */ +function segDistSq(p, a, b) { + let x = a[0], y = a[1] + let dx = b[0] - x, dy = b[1] - y + if (dx !== 0 || dy !== 0) { + const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy) + if (t > 1) { x = b[0]; y = b[1] } else if (t > 0) { x += dx * t; y += dy * t } + } + dx = p[0] - x + dy = p[1] - y + return dx * dx + dy * dy +} + +/** Douglas-Peucker, iterative so a long coastline cannot blow the stack. */ +function simplify(points, tolerance) { + if (points.length < 3) return points + const tolSq = tolerance * tolerance + const keep = new Uint8Array(points.length) + keep[0] = 1 + keep[points.length - 1] = 1 + const stack = [[0, points.length - 1]] + + while (stack.length) { + const [first, last] = stack.pop() + let maxSq = 0 + let index = 0 + for (let i = first + 1; i < last; i++) { + const d = segDistSq(points[i], points[first], points[last]) + if (d > maxSq) { maxSq = d; index = i } + } + if (maxSq > tolSq) { + keep[index] = 1 + stack.push([first, index], [index, last]) + } + } + return points.filter((_, i) => keep[i]) +} + +/** Every outer ring of a Polygon or MultiPolygon, holes discarded. */ +function outerRings(geometry) { + if (!geometry) return [] + if (geometry.type === 'Polygon') return geometry.coordinates.slice(0, 1) + if (geometry.type === 'MultiPolygon') return geometry.coordinates.map(poly => poly[0]) + return [] +} + +/** + * Lift a ring that straddles the antimeridian into a continuous longitude + * range. A ring whose points span more than half the globe is, in practice, + * always a wrap rather than a genuinely hemisphere-wide landmass. + */ +function unwrap(ring) { + let min = Infinity + let max = -Infinity + for (const [lng] of ring) { if (lng < min) min = lng; if (lng > max) max = lng } + if (max - min <= 180) return ring + return ring.map(([lng, lat]) => [lng < 0 ? lng + 360 : lng, lat]) +} + +function build(feature) { + const rings = outerRings(feature.geometry) + .map(unwrap) + .map(ring => ring.map(project)) + .map(ring => ({ ring, area: ringArea(ring) })) + .sort((a, b) => b.area - a.area) + + if (!rings.length) return null + + const biggest = rings[0].area + const kept = rings + .filter(r => r.area >= biggest * ISLAND_CUTOFF) + .slice(0, MAX_RINGS) + .map(r => r.ring) + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const ring of kept) { + for (const [x, y] of ring) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + } + const spanX = maxX - minX + const spanY = maxY - minY + if (!(spanX > 0) || !(spanY > 0)) return null + + // The longer side becomes 100 and the shorter one keeps its proportion, so a + // country never has to be squashed to fit the box it is drawn in. + const scale = 100 / Math.max(spanX, spanY) + const w = spanX * scale + const h = spanY * scale + const normalised = kept.map(ring => + ring.map(([x, y]) => [(x - minX) * scale, (y - minY) * scale])) + + // Tolerance climbs until the whole country fits its point budget. Starting + // from the diagonal keeps the loop short for a country of any size. + let tolerance = 0.14 + let simplified = normalised.map(r => simplify(r, tolerance)) + let guard = 0 + while (simplified.reduce((n, r) => n + r.length, 0) > MAX_POINTS && guard++ < 40) { + tolerance *= 1.35 + simplified = normalised.map(r => simplify(r, tolerance)) + } + + const d = simplified + .filter(ring => ring.length >= 3) + .map(ring => `M${ring.map(([x, y]) => `${round(x)} ${round(y)}`).join('L')}Z`) + .join('') + + // The projected bounds, kept so the same path can be replayed in world + // coordinates. A country drawn on its own wants its own box; the same country + // on a route map has to sit where it actually is, next to its neighbours. One + // normalised path plus these four numbers serves both, where storing two sets + // of coordinates would be twice the bytes and one more thing to disagree. + return d ? { d, w: round(w), h: round(h), b: [minX, minY, maxX, maxY].map(v => Math.round(v * 100) / 100) } : null +} + +const round = n => Math.round(n * 10) / 10 + +const raw = zlib.gunzipSync(fs.readFileSync(SRC)) +const collection = JSON.parse(raw.toString('utf8')) + +const shapes = new Map() +for (const feature of collection.features) { + const code = String(feature.properties?.ISO_A2 || '').toUpperCase() + if (code.length !== 2 || code === '-9') continue + const built = build(feature) + if (!built) continue + // A code appearing twice means two features for one country; keep the one + // with more outline, which is the mainland rather than a dependency. + const prev = shapes.get(code) + if (!prev || built.d.length > prev.d.length) shapes.set(code, built) +} + +const codes = [...shapes.keys()].sort() +const body = codes + .map(code => { + const s = shapes.get(code) + return ` ${code}: { d: '${s.d}', w: ${s.w}, h: ${s.h}, b: [${s.b.join(', ')}] },` + }) + .join('\n') + +const file = `/** + * GENERATED — do not edit. Run \`node scripts/build-country-shapes.mjs\`. + * + * Country silhouettes, simplified from the admin-0 boundaries the server + * bundles for Atlas (geoBoundaries, CC BY 4.0). Each path is Web Mercator, + * normalised so the longer side is 100 units; \`w\` and \`h\` give the shape's + * own proportions inside that box so it can be drawn without being squashed. + * + * ${codes.length} countries, built for print at postage-stamp size — islands + * below ${ISLAND_CUTOFF * 100}% of the mainland are dropped and each outline is + * simplified to at most ${MAX_POINTS} points. + */ + +export interface CountryShape { + /** Path on a 0..100 box, outer rings only. */ + d: string + /** The shape's own width and height inside that box. */ + w: number + h: number + /** Projected bounds \`[minX, minY, maxX, maxY]\`, for drawing it in place. */ + b: [number, number, number, number] +} + +/** + * Web Mercator, matching the projection the paths were built with. Longitude + * passes through as degrees; latitude is flipped so y grows downwards like + * SVG's does. + */ +export function projectMercator(lng: number, lat: number): { x: number; y: number } { + const clamped = Math.max(-85.05, Math.min(85.05, lat)) + const rad = (clamped * Math.PI) / 180 + return { x: lng, y: -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI } +} + +/** + * The same outline, placed in projected world coordinates. + * + * The stored path is normalised into its own box; multiplying by the box's span + * and shifting by its origin puts it back where the country actually is, which + * is what a map with more than one country on it needs. + */ +export function countryWorldPath(shape: CountryShape): string { + const span = Math.max(shape.b[2] - shape.b[0], shape.b[3] - shape.b[1]) / 100 + let axis = 0 + return shape.d.replace(/-?\\d*\\.?\\d+/g, m => { + const isX = axis++ % 2 === 0 + const v = Number(m) * span + (isX ? shape.b[0] : shape.b[1]) + return String(Math.round(v * 1000) / 1000) + }) +} + +export const COUNTRY_SHAPES: Record = { +${body} +} + +/** The silhouette for an ISO-3166-1 alpha-2 code, or null if we have none. */ +export function countryShape(code: string | null | undefined): CountryShape | null { + if (!code) return null + return COUNTRY_SHAPES[code.toUpperCase()] ?? null +} +` + +fs.writeFileSync(OUT, file, 'utf8') +const kb = Math.round(Buffer.byteLength(file) / 102.4) / 10 +console.log(`[country-shapes] ${codes.length} countries, ${kb} kB → ${path.relative(process.cwd(), OUT)}`) diff --git a/client/src/App.tsx b/client/src/App.tsx index 2512ca9d3..c9fc9edad 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -48,6 +48,7 @@ const HelpPage = lazyWithRetry(() => import('./pages/HelpPage')) const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage')) const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage')) const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage')) +const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage')) const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage')) const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage')) const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage')) @@ -513,7 +514,12 @@ export default function App() { } - /> + > + {/* Studio is nested so the journey stays mounted underneath it and + shows through the panel's margin — it is opened on top of the + journey, not navigated away to. */} + } /> + apiClient.get(`/journeys/${id}/entries`).then(r => r.data), // GPX tracks of the trips this journey's entries came from (#1260). listTracks: (id: number): Promise => apiClient.get(`/journeys/${id}/tracks`).then(r => r.data), + // What the journey adds up to — distance, days, countries, the route (#1973). + // Read by TREK Studio, which freezes the answer into the book document. + stats: (id: number): Promise => apiClient.get(`/journeys/${id}/stats`).then(r => r.data), + + // The Studio book (#1973). `book` is null for a journey that has none yet — + // Studio lays one out and the first save creates it. + getBook: (id: number): Promise<{ book: BookRecord | null }> => + apiClient.get(`/journeys/${id}/book`).then(r => r.data), + // A 409 carries the current record in its body, so a conflict can be shown + // rather than only announced. See useBookStore. + saveBook: (id: number, body: BookSaveRequest): Promise => + apiClient.put(`/journeys/${id}/book`, body).then(r => r.data), + deleteBook: (id: number): Promise => + apiClient.delete(`/journeys/${id}/book`).then(r => r.data), createEntry: (id: number, data: Record) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data), updateEntry: (entryId: number, data: Record) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data), deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data), diff --git a/client/src/api/websocket.ts b/client/src/api/websocket.ts index 00a1eddfa..0f5974648 100644 --- a/client/src/api/websocket.ts +++ b/client/src/api/websocket.ts @@ -9,6 +9,15 @@ let reconnectDelay = 1000 const MAX_RECONNECT_DELAY = 30000 const listeners = new Set() const activeTrips = new Set() + +/** + * Studio books this tab has open, so presence survives a reconnect. + * + * Same reasoning as `activeTrips`: the server forgets the room when the socket + * dies, and without rejoining, everyone else keeps showing a pointer for + * somebody who is no longer there. + */ +const activeBooks = new Set() let shouldReconnect = false let refetchCallback: RefetchCallback | null = null let mySocketId: string | null = null @@ -110,6 +119,11 @@ async function connectInternal(_isReconnect = false): Promise { socket.onopen = () => { reconnectDelay = 1000 + activeBooks.forEach(journeyId => { + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'book:join', journeyId })) + } + }) if (activeTrips.size > 0) { activeTrips.forEach(tripId => { if (socket && socket.readyState === WebSocket.OPEN) { @@ -166,6 +180,7 @@ export function disconnect(): void { reconnectTimer = null } activeTrips.clear() + activeBooks.clear() if (socket) { socket.onclose = null socket.close() @@ -187,6 +202,40 @@ export function leaveTrip(tripId: number | string): void { } } +/** + * Open a Studio book — presence and pointers, for as long as the editor is up. + */ +export function joinBook(journeyId: number | string): void { + activeBooks.add(String(journeyId)) + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'book:join', journeyId: String(journeyId) })) + } +} + +export function leaveBook(journeyId: number | string): void { + activeBooks.delete(String(journeyId)) + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'book:leave', journeyId: String(journeyId) })) + } +} + +/** + * Where this tab's pointer is, in millimetres on the spread. + * + * Dropped silently when the socket is not open. A pointer is worth nothing a + * moment later, so there is nothing to queue and nothing to retry — the next + * one is along in a tenth of a second. + */ +export function sendBookCursor( + journeyId: number | string, + spreadIndex: number, + x: number | null, + y: number | null, +): void { + if (!socket || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify({ type: 'book:cursor', journeyId: String(journeyId), spreadIndex, x, y })) +} + export function addListener(fn: WebSocketListener): void { listeners.add(fn) } diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index c86a5ec9a..f89b6dec6 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -57,6 +57,14 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ 'journey:entry:deleted', 'journey:entries:reordered', 'journey:contributor:changed', + // Studio book — components/Studio/useBookStore (its own listener: a client + // with nothing outstanding takes the new version, one with unsaved edits + // deliberately does not and conflicts on its next save instead) + 'journey:book:saved', + // Studio presence — components/Studio/useBookPresence (its own listener: + // who has the book open, and where their pointers are) + 'journey:book:peers', + 'journey:book:cursor', // Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener) 'import:progress', 'import:done', diff --git a/client/src/components/PDF/JourneyBookPDF.test.tsx b/client/src/components/PDF/JourneyBookPDF.test.tsx deleted file mode 100644 index d71bbcbca..000000000 --- a/client/src/components/PDF/JourneyBookPDF.test.tsx +++ /dev/null @@ -1,247 +0,0 @@ -// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-012 -// -// JourneyBookPDF.tsx exports an async function -// `downloadJourneyBookPDF(journey, { t, locale })` that renders a PDF preview in -// an srcdoc iframe overlay (Safari-safe pattern). Tests verify the overlay DOM -// structure, the HTML content and that the book follows the app language. - -import { describe, it, expect, vi, afterEach } from 'vitest'; - -// Mock `marked` so we don't need the real markdown parser -vi.mock('marked', () => ({ - marked: { - parse: (str: string) => `

${str}

`, - }, -})); - -import { downloadJourneyBookPDF } from './JourneyBookPDF'; -import type { JourneyDetail } from '../../store/journeyStore'; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function buildJourney(overrides: Partial = {}): JourneyDetail { - return { - id: 1, - user_id: 1, - title: 'Iceland Ring Road', - subtitle: 'Two weeks around the island', - status: 'active', - cover_image: null, - cover_gradient: null, - created_at: Date.now(), - updated_at: Date.now(), - entries: [ - { - id: 10, - journey_id: 1, - author_id: 1, - type: 'entry', - title: 'Golden Circle', - story: 'An incredible day of geysers and waterfalls.', - entry_date: '2026-07-01', - entry_time: '09:00', - location_name: 'Thingvellir', - location_lat: 64.255, - location_lng: -21.13, - mood: 'excited', - weather: 'sunny', - tags: [], - pros_cons: { pros: ['Amazing views'], cons: ['Crowded'] }, - visibility: 'private', - sort_order: 0, - created_at: Date.now(), - updated_at: Date.now(), - source_trip_id: null, - source_place_id: null, - source_trip_name: null, - photos: [ - { - id: 100, - entry_id: 10, - provider: 'local', - file_path: 'journey/geyser.jpg', - thumbnail_path: null, - asset_id: null, - owner_id: null, - shared: 0, - caption: 'Strokkur erupting', - sort_order: 0, - created_at: Date.now(), - }, - ], - }, - ], - trips: [], - contributors: [], - stats: { entries: 1, photos: 1, cities: 1 }, - ...overrides, - } as unknown as JourneyDetail; -} - -// ── Helpers to inspect the overlay ─────────────────────────────────────────── - -function getOverlay(): HTMLElement | null { - return document.getElementById('journey-pdf-overlay'); -} - -function getIframe(): HTMLIFrameElement | null { - return getOverlay()?.querySelector('iframe') ?? null; -} - -// Echoes the key (plus any params) so the assertions read as "this string comes -// from i18n" instead of pinning English copy. -function t(key: string, params?: Record): string { - if (!params) return key; - return `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})`; -} - -const longDate = (d: string, locale: string) => - new Date(d + 'T00:00:00').toLocaleDateString(locale, { - weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', - }); - -// ── Setup ──────────────────────────────────────────────────────────────────── - -afterEach(() => { - document.getElementById('journey-pdf-overlay')?.remove(); - vi.restoreAllMocks(); -}); - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe('downloadJourneyBookPDF', () => { - it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => { - await downloadJourneyBookPDF(buildJourney()); - expect(getOverlay()).not.toBeNull(); - expect(document.body.contains(getOverlay())).toBe(true); - }); - - it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => { - await downloadJourneyBookPDF(buildJourney()); - const iframe = getIframe(); - expect(iframe).not.toBeNull(); - const html = iframe!.srcdoc; - expect(html).toContain(''); - expect(html).toContain(''); - }); - - it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => { - await downloadJourneyBookPDF(buildJourney()); - const overlay = getOverlay()!; - expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull(); - expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull(); - }); - - it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => { - await downloadJourneyBookPDF(buildJourney()); - const html = getIframe()!.srcdoc; - expect(html).toContain('Iceland Ring Road'); - }); - - it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => { - await downloadJourneyBookPDF(buildJourney()); - const html = getIframe()!.srcdoc; - expect(html).toContain('Golden Circle'); - // Story text is rendered via markdown - expect(html).toContain('An incredible day of geysers and waterfalls.'); - // Pros/cons verdict cards are included - expect(html).toContain('Amazing views'); - expect(html).toContain('Crowded'); - }); - - it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => { - const journey = buildJourney({ entries: [] }); - await downloadJourneyBookPDF(journey); - expect(getOverlay()).not.toBeNull(); - const html = getIframe()!.srcdoc; - expect(html).toContain('Iceland Ring Road'); - // No entry pages, but cover and closing page are still present. Without a - // translator the chrome falls back to the raw keys instead of throwing. - expect(html).toContain('journey.pdf.journeyBook'); - expect(html).toContain('journey.pdf.theEnd'); - }); - - it('FE-COMP-JOURNEYPDF-007: sanitises HTML injected via an entry story and keeps the iframe script-free', async () => { - const journey = buildJourney(); - journey.entries[0].story = 'Hello world'; - await downloadJourneyBookPDF(journey); - const iframe = getIframe()!; - const html = iframe.srcdoc; - - // The script tag, image beacon and event handler are stripped from the story. - expect(html).not.toContain(' { - await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' }); - const html = getIframe()!.srcdoc; - - expect(html).toContain(''); - expect(html).toContain('journey.pdf.journeyBook'); - expect(html).toContain('journey.stats.days'); - expect(html).toContain('journey.stats.entries'); - expect(html).toContain('journey.stats.photos'); - expect(html).toContain('journey.pdf.madeWith'); - expect(html).toContain('journey.pdf.theEnd'); - // Day header is the parameterised key plus a date in the picked locale — - // the hardcoded 'en' formatting would not produce this string. - expect(html).toContain('journey.detail.day(number=1)'); - expect(html).toContain(longDate('2026-07-01', 'de-DE')); - // Pros/cons headings too. - expect(html).toContain('journey.verdict.lovedIt'); - expect(html).toContain('journey.verdict.couldBeBetter'); - }); - - it('FE-COMP-JOURNEYPDF-009: translates the preview bar above the book', async () => { - await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' }); - const overlay = getOverlay()!; - - // cover + one entry page + closing page - expect(overlay.textContent).toContain('3 journey.pdf.pages'); - expect(overlay.querySelector('#journey-pdf-save')!.textContent).toBe('journey.pdf.saveAsPdf'); - expect(overlay.querySelector('#journey-pdf-close')!.textContent).toBe('common.close'); - }); - - it('FE-COMP-JOURNEYPDF-010: prints mood and weather next to the entry', async () => { - const journey = buildJourney(); - journey.entries[0].mood = 'good'; - journey.entries[0].weather = 'rainy'; - await downloadJourneyBookPDF(journey, { t, locale: 'en-US' }); - const html = getIframe()!.srcdoc; - - expect(html).toContain('journey.mood.good'); - expect(html).toContain('journey.weather.rainy'); - // Icons come along as inline SVG inside the chips. - expect(html).toContain('class="entry-chips"'); - expect(html).toContain(' { - // The fixture carries mood 'excited', which is not one of the four moods. - await downloadJourneyBookPDF(buildJourney(), { t, locale: 'en-US' }); - const html = getIframe()!.srcdoc; - // Scoped to the chip row: the page elsewhere is free to carry whatever the - // fixture happens to leave undefined, and that is not what this asserts. - const chips = html.match(/
[\s\S]*?<\/div>/)?.[0] ?? ''; - - expect(chips).not.toBe(''); - expect(chips).not.toContain('undefined'); - expect(chips).not.toContain('journey.mood.'); - // The known weather value still gets its chip. - expect(chips).toContain('journey.weather.sunny'); - }); - - it('FE-COMP-JOURNEYPDF-012: leaves out the chip row when an entry has neither', async () => { - const journey = buildJourney(); - journey.entries[0].mood = null; - journey.entries[0].weather = null; - await downloadJourneyBookPDF(journey, { t, locale: 'en-US' }); - - expect(getIframe()!.srcdoc).not.toContain('class="entry-chips"'); - }); -}); diff --git a/client/src/components/PDF/JourneyBookPDF.tsx b/client/src/components/PDF/JourneyBookPDF.tsx deleted file mode 100644 index a7f267eb4..000000000 --- a/client/src/components/PDF/JourneyBookPDF.tsx +++ /dev/null @@ -1,391 +0,0 @@ -// Journey Photo Book PDF — Polarsteps-inspired, magazine-density -import { createElement } from 'react' -import type { LucideIcon } from 'lucide-react' -import { marked } from 'marked' -import { sanitizeRichTextHtml } from '@trek/shared' -import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore' -import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants' -import { renderIconMarkup } from '../../utils/iconMarkup' - -type Translate = (key: string, params?: Record) => string - -function esc(str: string | null | undefined): string { - if (!str) return '' - return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -} - -function md(str: string | null | undefined): string { - if (!str) return '' - // marked passes embedded raw HTML through by default, so sanitise the result - // before it goes into the srcdoc iframe (keeps prose markup, drops scripts). - return sanitizeRichTextHtml(marked.parse(str, { async: false, breaks: true }) as string) -} - -function abs(url: string | null | undefined): string { - if (!url) return '' - if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url - return window.location.origin + (url.startsWith('/') ? '' : '/') + url -} - -function pSrc(p: JourneyPhoto): string { - return abs(`/api/photos/${p.photo_id}/original`) -} - -function fmtDate(d: string, locale: string): string { - const date = new Date(d + 'T00:00:00') - return date.toLocaleDateString(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) -} - -function groupByDate(entries: JourneyEntry[]): Map { - const groups = new Map() - for (const e of entries) { - if (!e.entry_date) continue - if (!groups.has(e.entry_date)) groups.set(e.entry_date, []) - groups.get(e.entry_date)!.push(e) - } - return groups -} - -function renderProscons(entry: JourneyEntry, tr: Translate): string { - const pc = entry.pros_cons - if (!pc) return '' - const pros = pc.pros?.filter(p => p.trim()) || [] - const cons = pc.cons?.filter(c => c.trim()) || [] - if (pros.length === 0 && cons.length === 0) return '' - - return `
- ${pros.length > 0 ? `
${esc(tr('journey.verdict.lovedIt'))}
    ${pros.map(p => `
  • ${esc(p)}
  • `).join('')}
` : ''} - ${cons.length > 0 ? `
${esc(tr('journey.verdict.couldBeBetter'))}
    ${cons.map(c => `
  • ${esc(c)}
  • `).join('')}
` : ''} -
` -} - -function chip(icon: LucideIcon, label: string, bg: string, color: string): string { - const svg = renderIconMarkup(createElement(icon, { size: 12, strokeWidth: 2, color })) - return `${svg}${esc(label)}` -} - -/** - * Mood and weather were on screen but never in the book (#1848). Unknown values - * (older entries, a provider-set category we don't render) drop out silently, - * the same way MoodChip/WeatherChip return null for them. - */ -function renderMoodWeather(entry: JourneyEntry, tr: Translate): string { - const chips: string[] = [] - const mood = entry.mood ? MOOD_CONFIG[entry.mood] : undefined - if (mood) chips.push(chip(mood.icon, tr(mood.label), mood.bg, mood.text)) - const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : undefined - if (weather) chips.push(chip(weather.icon, tr(weather.label), '#f4f4f5', '#3f3f46')) - if (chips.length === 0) return '' - return `
${chips.join('')}
` -} - -function renderPhotoBlock(photos: JourneyPhoto[]): string { - if (photos.length === 0) return '' - if (photos.length === 1) { - return `
` - } - if (photos.length === 2) { - return `
${photos.map(p => `
`).join('')}
` - } - // 3+ photos: hero left + stack right - return `
-
-
-
-
-
-
` -} - -/** - * `opts` stays optional so the signature is additive, but every caller passes - * the page's `t`/`locale` — without them the book falls back to raw keys (#1848). - */ -export async function downloadJourneyBookPDF( - journey: JourneyDetail, - opts: { t?: Translate; locale?: string } = {}, -) { - const tr: Translate = opts.t || (key => key) - const loc = opts.locale || 'en' - const entries = (journey.entries || []).filter(e => e.type !== 'skeleton') - const allPhotos = entries.flatMap(e => e.photos || []) - const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '') - - const grouped = groupByDate(entries) - const dates = [...grouped.keys()].sort() - - // Build entry pages — one per entry, day header inline on first entry of day - const entryPages: string[] = [] - let pageNum = 1 // cover=1 - dates.forEach((date, di) => { - const dayEntries = grouped.get(date)! - dayEntries.forEach((entry, ei) => { - pageNum++ - const isFirstOfDay = ei === 0 - const photos = entry.photos || [] - const meta = [entry.entry_time, entry.location_name].filter(Boolean).join(' · ') - - // Day header (inline, only on first entry of day) - const dayHeaderHtml = isFirstOfDay - ? `
${esc(tr('journey.detail.day', { number: di + 1 }))} · ${esc(fmtDate(date, loc))}
` - : '' - - // Photo block - const photoHtml = renderPhotoBlock(photos) - - // Pros/cons - const prosconsHtml = renderProscons(entry, tr) - - // Story (markdown) - const storyHtml = entry.story ? `
${md(entry.story)}
` : '' - - entryPages.push(` -
- ${dayHeaderHtml} - ${photoHtml} -
- ${meta ? `` : ''} - ${renderMoodWeather(entry, tr)} - ${entry.title ? `

${esc(entry.title)}

` : ''} - ${storyHtml} - ${prosconsHtml} -
-
- `) - }) - }) - - const totalPages = pageNum + 1 // +1 for closing page - - const html = ` - - - - - - -${esc(journey.title)} — ${esc(tr('journey.pdf.journeyBook'))} - - - - - - -
- ${coverUrl ? `
` : ''} -
-
-
-
${esc(tr('journey.pdf.journeyBook'))}
-

${esc(journey.title)}

- ${journey.subtitle ? `
${esc(journey.subtitle)}
` : ''} -
-
${dates.length}
${esc(tr('journey.stats.days'))}
-
${entries.length}
${esc(tr('journey.stats.entries'))}
-
${allPhotos.length}
${esc(tr('journey.stats.photos'))}
-
-
- -
- - - ${entryPages.join('\n')} - - -
-
-
${esc(tr('journey.pdf.theEnd'))}
-
${esc(tr('journey.pdf.madeWith'))} · ${new Date().getFullYear()}
-
-
- - -` - - // Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF. - // This avoids window.open() which Safari iOS blocks in async callbacks - // and window.close() which doesn't work reliably in standalone PWA mode. - const overlay = document.createElement('div') - overlay.id = 'journey-pdf-overlay' - overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;' - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() } - - // The chrome needs a stylesheet rather than inline styles: on a phone the title - // and the two labels do not fit on one row, and translated labels are longer - // than the English ones. Below 640px the card goes full-bleed, the decorative - // title steps aside and the two actions share the width (#1848). - const style = document.createElement('style') - style.textContent = ` - #journey-pdf-overlay .jp-card { width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35); } - #journey-pdf-overlay .jp-head { display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a; } - #journey-pdf-overlay .jp-title { min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em; } - #journey-pdf-overlay .jp-actions { display:flex;align-items:center;gap:8px;flex:none; } - #journey-pdf-overlay .jp-btn { min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap; } - #journey-pdf-overlay .jp-save { border:none;background:#fff;color:#0f172a; } - #journey-pdf-overlay .jp-close { border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7); } - @media (max-width: 640px) { - #journey-pdf-overlay { padding:0; } - #journey-pdf-overlay .jp-card { height:100dvh;max-width:none;border-radius:0; } - #journey-pdf-overlay .jp-head { padding:8px 10px; } - #journey-pdf-overlay .jp-title { display:none; } - #journey-pdf-overlay .jp-actions { flex:1; } - #journey-pdf-overlay .jp-btn { flex:1;padding:10px 12px;font-size:13px; } - } - ` - - const card = document.createElement('div') - card.className = 'jp-card' - - const header = document.createElement('div') - header.className = 'jp-head' - header.innerHTML = ` - - - ` - - const iframe = document.createElement('iframe') - iframe.style.cssText = 'flex:1;width:100%;border:none;' - // No script runs inside the document (print is triggered from the parent via - // contentWindow.print()), so withhold allow-scripts to keep the sandbox tight. - iframe.sandbox = 'allow-same-origin allow-modals' - iframe.srcdoc = html - - card.appendChild(header) - card.appendChild(iframe) - overlay.appendChild(style) - overlay.appendChild(card) - document.body.appendChild(overlay) - - header.querySelector('#journey-pdf-close')!.onclick = () => overlay.remove() - header.querySelector('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() } -} diff --git a/client/src/components/Studio/BookSheetsView.tsx b/client/src/components/Studio/BookSheetsView.tsx new file mode 100644 index 000000000..ec0584230 --- /dev/null +++ b/client/src/components/Studio/BookSheetsView.tsx @@ -0,0 +1,176 @@ +import type { BookDocument } from '@trek/shared' +import { SpreadView } from './SpreadView' +import { MARK_LENGTH, MARK_WEIGHT, sheetBox, sheetsFor, type Sheet, type SheetMode } from './bookSheets' + +/** + * The book laid out for a press. + * + * ── One renderer ───────────────────────────────────────────────────────── + * + * These are the same `SpreadView`s the editor draws, at 1:1 with `print` on. + * That is the whole reason Studio renders in DOM and positions in millimetres: + * a second renderer for output is a second renderer to proofread against, and + * the day it drifts is the day someone finds out by opening a printed book. + * + * ── What is added on top ───────────────────────────────────────────────── + * + * Only what a press needs and a screen does not: the bleed carried out past the + * trim on all four sides, and crop marks outside it saying where to cut. + * Nothing here changes where anything sits on the page. + */ +export function BookSheetsView({ + doc, mode, marks, +}: { + doc: BookDocument + mode: SheetMode + /** Crop marks, and the room around the bleed they need. */ + marks: boolean +}) { + const sheets = sheetsFor(doc, mode) + + return ( +
+ {sheets.map((sheet, i) => ( + + ))} +
+ ) +} + +function SheetView({ + sheet, doc, marks, last, +}: { + sheet: Sheet + doc: BookDocument + marks: boolean + last: boolean +}) { + /* + * Each sheet is sized to what is on it, not to the widest in the book. + * + * A cover is one page wide and a spread is two, and in spread mode they sit + * in the same document. Making every sheet the wider of the two — so that one + * `@page` rule would cover them all — put a cover in the middle of a + * spread-sized page with white either side, which is not a cover. The print + * CSS carries a named page rule for the narrow ones instead. + */ + const box = sheetBox(sheet.width, sheet.height, doc.page.bleed, marks) + + return ( +
+ {/* + The window on the spread. One page wide plus bleed on both sides, which + is why a photograph crossing the gutter comes out aligned: both halves + are the same spread seen through openings a page apart. + */} +
+
+ +
+
+ + {marks && } +
+ ) +} + +/** + * Where to cut. + * + * Eight hairlines, two per corner, starting at the bleed edge and reaching + * outward — never crossing the trim, because a mark printed inside the trim is + * a mark that stays in the finished book. + */ +function CropMarks({ + box, width, height, +}: { + box: ReturnType + width: number + height: number +}) { + const left = box.margin + const top = box.margin + const right = left + width + const bottom = top + height + const gap = box.bleed + + const line = (x: number, y: number, w: number, h: number, key: string) => ( +
+ ) + + const marks = [] + for (const [x, dx] of [[left, -1], [right, 1]] as const) { + for (const [y, dy] of [[top, -1], [bottom, 1]] as const) { + // Horizontal arm, reaching out from the corner past the bleed. + marks.push(line( + dx < 0 ? x - gap - MARK_LENGTH : x + gap, + y - MARK_WEIGHT / 2, + MARK_LENGTH, MARK_WEIGHT, + `h${x}-${y}`, + )) + // Vertical arm. + marks.push(line( + x - MARK_WEIGHT / 2, + dy < 0 ? y - gap - MARK_LENGTH : y + gap, + MARK_WEIGHT, MARK_LENGTH, + `v${x}-${y}`, + )) + } + } + return <>{marks} +} diff --git a/client/src/components/Studio/PageNumbers.tsx b/client/src/components/Studio/PageNumbers.tsx new file mode 100644 index 000000000..7307b14b8 --- /dev/null +++ b/client/src/components/Studio/PageNumbers.tsx @@ -0,0 +1,98 @@ +import type { BookPageSetup, BookSpread } from '@trek/shared' +import { fontStack } from './bookFonts' +import { folioInk } from './folioColour' + +/** + * The folios. + * + * Drawn by the renderer rather than stored as elements, because the number a + * page carries is a function of where the spread sits in the book — and an + * element holding "14" would say 14 after the spread was moved, duplicated or + * deleted. Nothing else in the document has that property, which is why this is + * the one thing on the page that is not an element. + * + * That also settles where it belongs: inside `SpreadView`, so the editor and + * the print renderer produce the same number. Stamping them with pdf-lib + * afterwards would mean proofreading a book on screen whose page numbers are + * not the ones that get printed. + * + * The cover and the back cover carry none. A folio on a cover is a mistake in + * every book ever bound. + */ +export function PageNumbers({ + spread, page, spreadIndex, +}: { + spread: BookSpread + page: BookPageSetup + /** Position in the document, cover included. */ + spreadIndex: number +}) { + const cfg = page.pageNumbers + if (!cfg?.show || spread.role !== 'inner') return null + + /* + * The cover is one sheet with a front and a back, so the first inner spread + * opens on `startAt` — 2 by default, the page you see when you open a book + * whose cover is page 1. `spreadIndex` counts the cover, hence the -1. + */ + const left = cfg.startAt + (spreadIndex - 1) * 2 + const right = left + 1 + + const size = cfg.size + const y = page.pageHeight - cfg.margin + + /** Where the number sits on each page, given which edge it hangs from. */ + const place = (side: 'left' | 'right') => { + const pageX = side === 'left' ? 0 : page.pageWidth + if (cfg.position === 'centre') { + return { x: pageX, w: page.pageWidth, align: 'center' as const } + } + // Outer is the cut edge, inner is the gutter — the distinction only exists + // on a spread, and getting it backwards puts both numbers in the fold. + const outward = cfg.position === 'outer' + const atLeftEdge = side === 'left' ? outward : !outward + return atLeftEdge + ? { x: pageX + cfg.margin, w: page.pageWidth * 0.4, align: 'left' as const } + : { x: pageX + page.pageWidth * 0.6 - cfg.margin, w: page.pageWidth * 0.4, align: 'right' as const } + } + + return ( + <> + {([['left', left], ['right', right]] as const).map(([side, number]) => { + const at = place(side) + /* + * Sampled at the middle of the number's own box, which is where the + * digits actually are — the box is 40% of the page wide so that the + * text can align inside it, and its left edge is often over something + * else entirely. + */ + const ink = cfg.autoColor + ? folioInk(spread, at.x + at.w / 2, y - size * 0.18) + : { color: cfg.color, shadow: undefined } + return ( +
+ {number} +
+ ) + })} + + ) +} diff --git a/client/src/components/Studio/PeerBadges.tsx b/client/src/components/Studio/PeerBadges.tsx new file mode 100644 index 000000000..5fdaaeaca --- /dev/null +++ b/client/src/components/Studio/PeerBadges.tsx @@ -0,0 +1,52 @@ +import type { BookPeer } from './useBookPresence' +import { peerColour } from './peerColour' + +/** + * Who else has the book open. + * + * In the bar rather than in a panel, because it answers a question people ask + * before they start moving things: am I the only one in here. A pointer answers + * it too, but only once the other person moves — someone reading a spread in + * silence is invisible without this. + * + * The same colour as their pointer, so the two read as one person. + */ +export function PeerBadges({ peers, t }: { peers: BookPeer[]; t: (k: string) => string }) { + if (peers.length === 0) return null + + /* + * By person, not by socket. + * + * The presence list is keyed by socket because two tabs are two pointers, and + * that is right for pointers. It is wrong here: one person with the book open + * twice is one person, and showing them as two says the room is busier than + * it is. + */ + const seen = new Map() + for (const p of peers) if (!seen.has(p.userId)) seen.set(p.userId, p) + const people = [...seen.values()] + + const shown = people.slice(0, 4) + const rest = people.length - shown.length + + return ( +
p.username).join(', ')}> + + {shown.map(p => ( + + {p.avatar + ? + : (p.username?.[0] ?? '?').toUpperCase()} + + ))} + + {rest > 0 && +{rest}} + {t('journey.studio.peersHere')} +
+ ) +} diff --git a/client/src/components/Studio/PeerCursors.tsx b/client/src/components/Studio/PeerCursors.tsx new file mode 100644 index 000000000..4d57d0576 --- /dev/null +++ b/client/src/components/Studio/PeerCursors.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from 'react' +import type { PeerCursor } from './useBookPresence' +import { peerColour } from './peerColour' + +/** + * The other people's pointers. + * + * ── Why they are interpolated ──────────────────────────────────────────── + * + * They arrive about ten times a second, because sending sixty would be five + * times the traffic for something the eye cannot use. Drawn as they arrive, + * that reads as a stutter; drawn towards where they last were, it reads as a + * hand moving. The catch-up is deliberately quick — a pointer that eases + * elegantly into place is a pointer that is telling you where somebody was a + * third of a second ago. + * + * The colour is derived from the person's id — see peerColour.ts. + */ + +/** How much of the remaining distance is covered per frame. */ +const EASE = 0.28 + +/** Below this, in millimetres, the pointer is simply put where it belongs. */ +const SNAP_MM = 0.15 + +export function PeerCursors({ + cursors, spreadIndex, zoom, +}: { + cursors: PeerCursor[] + /** Only the pointers on the spread being looked at are drawn. */ + spreadIndex: number + /** The sheet's scale, so the arrow can undo it and stay one size. */ + zoom: number +}) { + const here = cursors.filter(c => c.spreadIndex === spreadIndex) + if (here.length === 0) return null + + return ( + <> + {here.map(cursor => ( + + ))} + + ) +} + +function Pointer({ cursor, zoom }: { cursor: PeerCursor; zoom: number }) { + const [at, setAt] = useState({ x: cursor.x, y: cursor.y }) + const target = useRef({ x: cursor.x, y: cursor.y }) + const frame = useRef(null) + + target.current = { x: cursor.x, y: cursor.y } + + useEffect(() => { + const step = () => { + setAt(current => { + const dx = target.current.x - current.x + const dy = target.current.y - current.y + if (Math.abs(dx) < SNAP_MM && Math.abs(dy) < SNAP_MM) { + frame.current = null + return target.current + } + frame.current = requestAnimationFrame(step) + return { x: current.x + dx * EASE, y: current.y + dy * EASE } + }) + } + if (frame.current == null) frame.current = requestAnimationFrame(step) + return () => { + if (frame.current != null) cancelAnimationFrame(frame.current) + frame.current = null + } + }, [cursor.x, cursor.y]) + + const colour = peerColour(cursor.userId) + + return ( +
+ {/* + The arrow itself. Drawn rather than an icon so the fill can be the + person's colour and the outline can stay white — a solid pointer + disappears into a dark photograph, and a white one into a white page. + */} + {/* + Bigger than it first was: at 14px the 1.2 outline ate most of the fill + and every pointer read as the same white arrow. The outline is what + keeps it visible on a dark photograph, so the arrow grew instead. + */} + + + + {cursor.username && ( + + {cursor.username} + + )} +
+ ) +} diff --git a/client/src/components/Studio/SaveIndicator.tsx b/client/src/components/Studio/SaveIndicator.tsx new file mode 100644 index 000000000..746880022 --- /dev/null +++ b/client/src/components/Studio/SaveIndicator.tsx @@ -0,0 +1,74 @@ +import { AlertTriangle, Check, CloudOff, RefreshCw } from 'lucide-react' +import type { BookRecord } from '@trek/shared' +import type { SaveState } from './useBookStore' + +/** + * Whether the book is safe. + * + * Autosave without a sign of it is a promise you are asking someone to take on + * faith, and the moment they do not is the moment they close the tab to check. + * So: a tick when it is written, a spinner while it is being written, and — the + * one that matters — a real choice when someone else got there first. + * + * Quiet by design. This sits in the top bar of an editor, and a status that + * announces every successful save is a status people learn to stop reading, + * which is exactly the wrong habit for the one time it says something else. + */ +export function SaveIndicator({ + state, t, onAcceptTheirs, onKeepMine, onRetry, +}: { + state: SaveState + t: (k: string) => string + onAcceptTheirs: (current: BookRecord) => void + onKeepMine: (current: BookRecord) => void + onRetry: () => void +}) { + if (state.status === 'conflict') { + return ( +
+ + {t('journey.studio.saveConflict')} + {/* + Two ways out, both stated plainly. There is no merge here and + pretending otherwise would be worse than saying so: one of these + documents is going to be the book. + */} + + +
+ ) + } + + if (state.status === 'error') { + return ( + + ) + } + + if (state.status === 'saving') { + return ( +
+ + {t('journey.studio.saving')} +
+ ) + } + + if (state.status === 'saved') { + return ( +
+ + {t('journey.studio.saved')} +
+ ) + } + + return null +} diff --git a/client/src/components/Studio/SpreadView.tsx b/client/src/components/Studio/SpreadView.tsx new file mode 100644 index 000000000..da6d8c032 --- /dev/null +++ b/client/src/components/Studio/SpreadView.tsx @@ -0,0 +1,523 @@ +import type { CSSProperties } from 'react' +import type { + BookElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread, +} from '@trek/shared' +import { FONT_STACKS, photoSrc } from './bookRender' +import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes' +import { TravelElementView } from './TravelElements' +import { PageNumbers } from './PageNumbers' + +/** + * One spread, drawn. + * + * This component is the whole reason Studio renders in DOM rather than on a + * canvas: the *same* tree is what the print renderer will run in headless + * Chromium. Edit mode adds handles and outlines on top; print mode is this and + * nothing else. There is no second renderer to drift against. + * + * Everything is positioned in millimetres. CSS maps mm onto the PDF with a fixed + * ratio, so what you see at `scale(0.4)` is the same box model the printer gets + * at 1:1 — not an approximation of it. + */ + +function frameStyle(el: BookElement): CSSProperties { + return { + position: 'absolute', + left: `${el.frame.x}mm`, + top: `${el.frame.y}mm`, + width: `${el.frame.w}mm`, + height: `${el.frame.h}mm`, + opacity: el.opacity, + transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined, + } +} + +/** Two decimals of a millimetre, matching what the document itself stores. */ +const round2 = (n: number) => Math.round(n * 100) / 100 + +/** #rrggbb plus an alpha, for the fades a cover panel needs. */ +function hexToRgba(hex: string, alpha: number): string { + const n = parseInt(hex.slice(1), 16) + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})` +} + +const FILTERS: Record = { + none: undefined, + bw: 'grayscale(1) contrast(1.05)', + warm: 'saturate(1.1) sepia(0.16)', + cool: 'saturate(1.05) hue-rotate(-8deg) brightness(1.02)', + // Faded, the way a print left in the light goes: lifted blacks, less colour. + fade: 'saturate(0.72) contrast(0.88) brightness(1.08)', + contrast: 'contrast(1.22) saturate(1.06)', +} + +/** + * How much of the frame the decoration around a picture eats, as a fraction of + * the shorter side. + * + * A Polaroid's chin is the reason this is a table rather than one number: the + * bottom border is roughly three times the others, and getting that ratio wrong + * is the difference between a Polaroid and a photo with a white edge. + */ +const FRAME_INSET: Record = { + none: { pad: 0, bottom: 0 }, + polaroid: { pad: 0.055, bottom: 0.17 }, + white: { pad: 0.035, bottom: 0.035 }, + shadow: { pad: 0, bottom: 0 }, + film: { pad: 0.075, bottom: 0.075 }, + tape: { pad: 0, bottom: 0 }, +} + +/** + * A shape, drawn. + * + * Rectangles and ellipses stay `
`s — a box with a corner radius is exactly + * what CSS is good at, and those two are what every document written before the + * shape library existed contains. Everything else is an SVG path. + * + * The viewBox is in millimetres and the element is that many millimetres wide, + * so one user unit is one millimetre and a stroke width means the same thing on + * every side. The path is drawn into a box inset by half the stroke, which puts + * the whole stroke inside the frame the editor snapped — the same thing + * `box-sizing: border-box` does for the div case, and the reason a snapped edge + * and a drawn edge line up. + */ +function ShapeView({ el }: { el: BookShapeElement }) { + const fill = el.fill ?? 'transparent' + const gradientId = `g-${el.id}` + const gradient = el.gradient !== 'none' && el.fill + + if (el.shape === 'rect' || el.shape === 'ellipse') { + const background = !gradient + ? fill + : `linear-gradient(${el.gradient === 'up' ? 'to top' : 'to bottom'},` + + ` ${hexToRgba(el.fill!, 0)} 0%,` + + ` ${hexToRgba(el.fill!, 0.55)} 46%,` + + ` ${hexToRgba(el.fill!, 1)} 100%)` + return ( +
+ ) + } + + const sw = el.stroke ? el.strokeWidth : 0 + const w = Math.max(0.01, el.frame.w - sw) + const h = Math.max(0.01, el.frame.h - sw) + const d = scalePath(SHAPE_PATHS[el.shape], w, h) + + // Proportional to the stroke, so a dashed outline keeps its rhythm whether it + // is a hairline on a caption rule or a 2mm band around a cover panel. + const dash = el.strokeStyle === 'dashed' ? `${sw * 3} ${sw * 2}` + : el.strokeStyle === 'dotted' ? `${sw * 0.01} ${sw * 2}` + : undefined + + return ( +
+ + {gradient && ( + + + + + + + + )} + + +
+ ) +} + +/** + * A picture — possibly cut to a shape, possibly sitting in something. + * + * The mask and the decoration are independent on purpose: a heart in a Polaroid + * is a silly thing to want and there is no reason to stop anyone wanting it, and + * keeping them separate means neither has to know about the other. + */ +function PhotoView({ el, big, print, dropLabel }: { + el: BookPhotoElement; big: boolean; print: boolean; dropLabel: string +}) { + const deco = FRAME_INSET[el.frameStyle] ?? FRAME_INSET.none + const side = Math.min(el.frame.w, el.frame.h) + // Rounded to a hundredth of a millimetre — the same precision the document + // stores. Without it a fraction of the frame lands in the CSS as + // `10.200000000000001mm`, which is not wrong but is float noise in a + // stylesheet that a person may well end up reading. + const pad = round2(deco.pad * side) + const bottom = round2(deco.bottom * side) + const clipId = `c-${el.id}` + const clipped = el.mask && el.mask !== 'rect' + + /* + * An empty frame is a template's promise: it says where a picture goes before + * anyone has chosen which — and it says it *in the frame it will wear*. + * + * This used to render on its own, ignoring frameStyle entirely, so a Polaroid + * placeholder was an ordinary dashed rectangle until a photograph landed in + * it and the chin appeared from nowhere. The two branches have been folded + * together: the surround is drawn once, and only what goes inside it differs. + */ + const empty = el.photoId == null + + // In the printed book an unfilled frame is nothing at all — a hatch and an + // instruction on a page someone paid to have bound would be a defect. + if (empty && print) return null + + // The label is sized in millimetres so it scales with the zoom exactly as the + // page does, and it steps aside on a frame too small to hold it. + const labelSize = Math.max(2.4, Math.min(4.6, side * 0.085)) + const roomy = side - pad * 2 > 22 && el.frame.w - pad * 2 > 34 + + const hatch = ( +
+ {roomy && dropLabel && ( + + {dropLabel} + + )} +
+ ) + + const picture = ( +
+ {empty ? hatch : ( + + )} +
+ ) + + return ( +
+ {clipped && ( + + + + + + + + )} + + {picture} + + {/* Sprocket holes. Drawn as a repeating gradient rather than as elements + so the count follows the width instead of being fixed at a size that + only looks right on one frame. */} + {el.frameStyle === 'film' && [0, 1].map(row => ( +
+ ))} + + {/* Two strips of tape, held at an angle across opposite corners. */} + {/* + Two strips, one over each top corner, each centred *on* its corner so it + overhangs the picture evenly on both sides — which is what a piece of + tape does. Offsetting by a fraction of the strip instead left them + sitting beside the corners rather than across them. + */} + {el.frameStyle === 'tape' && ([-1, 1] as const).map(dir => { + const tapeW = side * 0.26 + const tapeH = side * 0.075 + return ( +
+ ) + })} +
+ ) +} + +export function ElementView({ + el, big, print = false, dropLabel = '', +}: { el: BookElement; big: boolean; print?: boolean; dropLabel?: string }) { + if (el.kind === 'photo') { + return + } + + if (el.kind === 'shape') return + + if (el.kind !== 'text') { + // map, stats, countries, badge — the elements drawn from the journey's own + // figures. They live in their own file because they are a different kind of + // drawing: type and vector graphics laid out from data, rather than one box + // with one property set. + return + } + + return ( +
+ {el.text} +
+ ) +} + +/** + * The sheet. `mode="print"` is exactly what the renderer will produce; the + * editor draws the same thing and layers its chrome above it. + */ +export function SpreadView({ + spread, + page, + spreadIndex = 0, + big = false, + showGuides = false, + print = false, + dropLabel = '', +}: { + spread: BookSpread + page: BookPageSetup + /** + * Where this spread sits in the book. Only the folios need it — a page + * number is the one thing on the page that is a function of position rather + * than of the document. + */ + spreadIndex?: number + big?: boolean + showGuides?: boolean + /** The print renderer passes this: no guides, no placeholders, no chrome. */ + print?: boolean + dropLabel?: string +}) { + const isSingle = spread.role !== 'inner' + const w = isSingle ? page.pageWidth : page.pageWidth * 2 + + return ( +
+ {spread.elements.map(el => ( + + ))} + + + + {showGuides && ( + <> + {/* Safe area, per page: on a spread the inner margin belongs to the + gutter, so one box around the whole sheet would be a lie. */} + {(isSingle ? [0] : [0, page.pageWidth]).map(offset => ( +
+ ))} +
+ + )} +
+ ) +} + + +/** + * The fold down the middle of an open book. + * + * Preview chrome, not content — which is why it lives outside `SpreadView`. A + * printed book has a physical crease; a *printed* shadow down the gutter would + * be a defect. So the editor and the page thumbnails draw this, and the renderer + * never sees it. + * + * Two layers, because that is what makes paper read as curving rather than as a + * grey stripe: the shadow ramps into the spine and darkens hard at the crease, + * and just outside it a pale band lifts, the way the sheet catches light as it + * comes back up out of the binding. + */ +export function SpreadFold({ page, scaled }: { page: BookPageSetup; scaled: number }) { + // Wide on purpose: the lift has to ramp over a long distance to read as + // paper curving. A narrow band reads as two painted stripes instead. + const width = 52 * scaled + const left = page.pageWidth * scaled - width / 2 + return ( +
+
+ {/* The crease itself. Sub-pixel at small zoom, which is right — you should + not see a hard line on a page shown at 15%. */} +
+
+ ) +} diff --git a/client/src/components/Studio/StudioCanvas.tsx b/client/src/components/Studio/StudioCanvas.tsx new file mode 100644 index 000000000..7192cf815 --- /dev/null +++ b/client/src/components/Studio/StudioCanvas.tsx @@ -0,0 +1,453 @@ +import { useEffect, useState } from 'react' +import { ChevronsDown, ChevronsUp, Copy, Lock, RotateCcw, RotateCw, Trash2, Unlock } from 'lucide-react' +import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared' +import { SpreadFold, SpreadView } from './SpreadView' +import { PeerCursors } from './PeerCursors' +import type { PeerCursor } from './useBookPresence' +import { FONT_STACKS } from './bookRender' +import { useSpreadInteraction, type HandleId } from './useSpreadInteraction' +import { useStudioStore } from '../../store/studioStore' + +const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] + +/** + * Where the four rotation handles sit, as percentages of the selection box. + * + * The push diagonally outwards lives in the stylesheet rather than here: it is + * a fixed number of screen pixels clear of the resize handle on the same + * corner, so it must not scale with the element the way a percentage would. + */ +const ROTATE_CORNERS = [ + { id: 'nw', left: '0%', top: '0%' }, + { id: 'ne', left: '100%', top: '0%' }, + { id: 'se', left: '100%', top: '100%' }, + { id: 'sw', left: '0%', top: '100%' }, +] as const + +const HANDLE_POS: Record = { + nw: { left: '0%', top: '0%', cursor: 'nwse-resize' }, + n: { left: '50%', top: '0%', cursor: 'ns-resize' }, + ne: { left: '100%', top: '0%', cursor: 'nesw-resize' }, + e: { left: '100%', top: '50%', cursor: 'ew-resize' }, + se: { left: '100%', top: '100%', cursor: 'nwse-resize' }, + s: { left: '50%', top: '100%', cursor: 'ns-resize' }, + sw: { left: '0%', top: '100%', cursor: 'nesw-resize' }, + w: { left: '0%', top: '50%', cursor: 'ew-resize' }, +} + +/** + * The sheet plus everything you do to it. + * + * The page itself is `SpreadView`, byte for byte what the print renderer will + * draw. Selection outlines, handles and snap guides live in a layer *above* it + * and never touch the document, so nothing you see while editing can end up in + * the book. + */ +export function StudioCanvas({ + spread, + spreadIndex, + page, + zoom, + pxPerMm, + bookView, + dropLabel, + cursors, + onCursor, +}: { + spread: BookSpread | null + spreadIndex: number + page: BookPageSetup + zoom: number + pxPerMm: number + bookView: boolean + dropLabel: string + /** The other editors' pointers, on this spread and elsewhere. */ + cursors?: PeerCursor[] + /** Where this one is, in the spread's millimetres. Null once it leaves. */ + onCursor?: (x: number | null, y: number | null) => void +}) { + const selection = useStudioStore(s => s.selection) + const select = useStudioStore(s => s.select) + const removeElements = useStudioStore(s => s.removeElements) + const duplicate = useStudioStore(s => s.duplicate) + const raise = useStudioStore(s => s.raise) + const commit = useStudioStore(s => s.commit) + const addElement = useStudioStore(s => s.addElement) + const [dropTarget, setDropTarget] = useState(null) + const [editing, setEditing] = useState(null) + + /** Where on the spread, in millimetres, a drop landed. */ + /** + * Turn the selection by a number of degrees. + * + * Kept in the range a person reads as an angle rather than as a winding + * number: -180 to 180, so an element turned all the way round reads as + * straight rather than as 360. + */ + const rotateBy = (deg: number) => { + commit(d => ({ + ...d, + spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : { + ...sp, + elements: sp.elements.map(e => { + if (!selection.includes(e.id) || e.locked) return e + let next = (e.rotation + deg) % 360 + if (next > 180) next -= 360 + if (next < -180) next += 360 + return { ...e, rotation: Math.round(next * 10) / 10 } + }), + })), + })) + } + + const pointInMm = (e: React.DragEvent) => { + const r = e.currentTarget.getBoundingClientRect() + return { x: (e.clientX - r.left) / scaled, y: (e.clientY - r.top) / scaled } + } + + const frameUnder = (x: number, y: number) => spread?.elements.find(el => + el.kind === 'photo' + && !el.locked + && x >= el.frame.x && x <= el.frame.x + el.frame.w + && y >= el.frame.y && y <= el.frame.y + el.frame.h, + ) ?? null + const scaled = pxPerMm * zoom + + const { guides, dragging, startMove, startResize, startRotate, onPointerMove, finish } = useSpreadInteraction({ + spread, spreadIndex, page, pxPerMm: scaled, + }) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) { + e.preventDefault() + removeElements(spreadIndex, selection) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [selection, spreadIndex, removeElements]) + + if (!spread) return null + + const single = spread.role !== 'inner' + const sheetW = single ? page.pageWidth : page.pageWidth * 2 + const sel = spread.elements.filter(e => selection.includes(e.id)) + + return ( +
{ + onPointerMove(e) + if (!onCursor) return + const r = e.currentTarget.getBoundingClientRect() + onCursor((e.clientX - r.left) / scaled, (e.clientY - r.top) / scaled) + }} + // Leaving the stage sends null, so the arrow goes rather than sticking + // where the pointer happened to cross the edge. + onPointerLeave={() => onCursor?.(null, null)} + onPointerUp={finish} + onPointerCancel={finish} + onDragOver={e => { + if (!e.dataTransfer.types.includes('application/x-trek-photo')) return + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + const p = pointInMm(e) + setDropTarget(frameUnder(p.x, p.y)?.id ?? null) + }} + onDragLeave={() => setDropTarget(null)} + onDrop={e => { + const raw = e.dataTransfer.getData('application/x-trek-photo') + if (!raw) return + e.preventDefault() + setDropTarget(null) + const photoId = Number(raw) + if (!Number.isFinite(photoId)) return + + const p = pointInMm(e) + const target = frameUnder(p.x, p.y) + if (target) { + // Dropped onto a frame: fill it. That is the whole point of an empty + // frame, and replacing a picture is the natural way to swap one. + commit(d => ({ + ...d, + spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : { + ...sp, + elements: sp.elements.map(el => (el.id === target.id ? { ...el, photoId } : el)), + })), + })) + select([target.id]) + return + } + + // Dropped on bare paper: a new frame, centred on the cursor. + const w = Math.min(page.pageWidth, page.pageHeight) * 0.5 + const h = w * 0.72 + const id = `p-${Math.random().toString(36).slice(2, 9)}` + addElement(spreadIndex, { + id, kind: 'photo', + frame: { x: p.x - w / 2, y: p.y - h / 2, w, h }, + rotation: 0, opacity: 1, locked: false, + photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', + mask: null, frameStyle: 'none', + } as BookElement) + select([id]) + }} + > +
select([])} + > + 0.34} showGuides dropLabel={dropLabel} /> + + {cursors && cursors.length > 0 && ( + + )} + + {/* Hit targets sit above the page so a photo's own never eats the + gesture, and so a locked element simply is not grabbable. */} + {spread.elements.map(el => ( +
{ if (editing !== el.id) startMove(e, el) }} + onDoubleClick={() => { if (el.kind === 'text' && !el.locked) setEditing(el.id) }} + style={{ + position: 'absolute', + left: `${el.frame.x}mm`, + top: `${el.frame.y}mm`, + width: `${el.frame.w}mm`, + height: `${el.frame.h}mm`, + transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined, + cursor: el.locked ? 'default' : 'move', + // The target stays live even when locked, or the element could + // not be selected — and an element that cannot be selected cannot + // be unlocked. + pointerEvents: 'auto', + }} + /> + ))} +
+ + {/* + Editing happens *on the page*, in the element's own type at the element's + own size. A dialog with a text box would be easier to build and would + make you guess how the words break — the whole reason to type here is to + watch the line endings while you do it. + */} + {editing && (() => { + const el = spread.elements.find(e => e.id === editing) + if (!el || el.kind !== 'text') return null + return ( +