From 736599a89b5a99824284834a3fc3ca31ff1565dd Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:36:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(journey):=20TREK=20Studio=20=E2=80=94=20a?= =?UTF-8?q?=20photo=20book=20designer=20for=20Journey=20(#1973)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(photos): serve local photos root-relative so a dotted install path works res.sendFile(absolutePath) resolves against the rewritten req.url under the Nest ExpressAdapter and answers a spurious 404 even though the file is there — the same trap files-download.controller.ts:60 and plugin-frame.controller.ts:74 already work around with an explicit { root } + basename. The photo resolver never got that treatment, so every thumbnail and original 404s on any install whose uploads path contains a dot segment (~/.local/share/trek and friends). The public journey asset route had it too, which took shared journeys with it. The guards above each call already confine the path to the uploads tree, so the root form is no weaker than what it replaces. * feat(shared): add the book document contract for TREK Studio Millimetres throughout, not pixels: a pixel means nothing to a print shop and changes meaning with the device pixel ratio. CSS maps 1in onto 96px, 25.4mm and 72pt with a fixed ratio, so the editor can render the document at a scale and the print renderer at 1:1 and land on the same page. Paint order is the array order rather than a zIndex field that can disagree with itself, text elements may stay bound to the journal entry they came from, and a spread can park content a layout has no room for instead of dropping it. * feat(journey): open TREK Studio over the journey Studio is a child route of /journey/:id and portals itself above it, so the journey stays mounted and shows through the panel's 16px margin — it reads as something opened on top of the journey rather than a place you navigated to. The URL exists for reload, the back button and shared links; the user never sees it. Every rule in studio.css is scoped under .trek-dash on purpose: dashboard.css:95 resets button font, colour, background and padding at a specificity a bare .st-x loses to, so an unscoped rule silently does not apply to a button. Escape clears the selection and only leaves on a second press. In a dialog Escape means go away; in an editor that reflex would cost you the page. * feat(journey): build, edit and arrange the book in Studio The auto mode turns a journey into a book once and then gets out of the way: what it produces is an ordinary document, every element as editable as one placed by hand. Templates work the same way — applying one pours the spread's existing photos and text into the new arrangement and parks whatever will not fit, so trying a text-only layout does not cost you the pictures. Moving and resizing are hand-rolled on pointer events. The drag-and-drop libraries in this space move items between lists and have no notion of a free frame, a handle or a magnetic edge; HTML5 drag hands you a ghost image you cannot control and no position until the drop. It is used for exactly the one shape it suits — dragging a photo out of the content panel onto a frame. Selection chrome carries two rings, dark inside light. A single line vanishes against a photograph of its own value, and over a picture that is bright on one side and dark on the other no measurement of the background can be right. The sheet renders in DOM rather than on a canvas so the same component tree can be what the print renderer runs — one renderer, nothing to drift against. Edit affordances live in a layer above it and can never reach the page. * i18n(journey): studio strings across all 23 locales The empty-frame prompt breaks per language rather than copying the English line break onto everything — forced onto Japanese or Arabic it strands a fragment on the second line. * refactor(journey): keep SpreadView to components only FONT_STACKS and photoSrc move to their own module: a file that exports both components and constants breaks Fast Refresh, and this is a file you edit constantly while designing a page. Types come from the journey store instead of any, and the count beside the content tabs is a badge rather than competing with the label for the same weight. * feat(journey): more shapes, decoration and a content search in Studio Six shapes now instead of three — rounded rectangle, triangle and an outline-only box join the rest, plus a quotation mark and an outlined circle under decoration. A triangle is a clip path rather than the old CSS border trick: that hack cannot take a fill gradient or a stroke and its size does not follow the frame the way everything else does. A stroke sits inside the frame, or the drawn shape would be wider than the box the editor snapped and the print would not match it. The content panel gets a search over photos and entries. A photo matches on its own caption and on the entry it hangs from — most photos carry no words at all, so matching captions alone would look broken on exactly the journeys that need it. Plain filtering over what is already in memory; a journey holds tens or hundreds of items, not thousands. Dates in the panel and in the book now follow the app's language through the house formatDate rather than the browser's locale, and the entry's date and place are two badges instead of one dot-joined string the eye has to parse. * @ feat(journey): a shape library, picture frames and photo grids for Studio Studio had six shapes, two frames and no grids. This is the library that makes it usable for a book rather than for a diagram. Shapes are SVG paths now, not clip-paths. That was forced rather than chosen: a clipped div cannot take a stroke — the clip cuts the border in half lengthwise — and a heart is not a box with a radius. Rectangles and ellipses keep their old div rendering, since a box genuinely is better as a box and every document written so far contains those two. Two decisions in shapes.ts are worth stating. The path *points* are scaled, not the coordinate system. Fitting a path with preserveAspectRatio="none" scales the stroke with it, so a 0.5mm outline on a frame twice as wide as it is tall comes out 0.7mm on the sides and 0.35mm top and bottom. Printed next to a straight rule at 300dpi, you see it. Hence the deliberately boring path subset: absolute M/L/C/Q/Z, numbers alternating x and y, no arcs. And every shape fills its 0..100 box exactly, enforced by normalise() at module load rather than asked for in a comment. The editor draws its selection outline, its resize handles and every snap target from the element frame, so a shape occupying 86% of its own frame cannot be aligned against anything you can see. It costs a regularity nobody can spot — a hexagon in a square frame still reads as a hexagon. Frames are the same list: a picture frame is a shape with a photograph inside it, and keeping two would mean a heart that exists as one but not the other. Photos gain a mask and a decoration, kept independent, plus the Polaroid, film and tape treatments that are not expressible as either. Grids take their gutter out of the cells rather than adding it between them, so a grid asked to fill the safe area fills it instead of overhanging by one gutter. @ * @ feat(journey): derive what a journey adds up to GET /api/journeys/:id/stats — distance, days, steps, photos, places, the furthest point, the countries in visit order, and the route itself. Read only and derived: nothing here is stored, it is what the trips added to the journey come to. The route comes from the journey entries where they carry coordinates, and falls back to the places on the linked trips where they do not — which is the common case for a journey assembled from trips nobody has written up yet. The two are never mixed: an entry written about a place that is also on the trip would be two stops at one location, and the distance would count that leg twice. Countries read place_regions first, which Atlas already fills, and fall through to getCountryFromCoords for the rest. That import is a plain function from a module built without DI or Nest edges, so it costs the journey domain no coupling to Atlas. Distance is great-circle metres, not driving distance: there is no routing engine here, consecutive stops are not necessarily joined by a road, and a book that prints a road distance it never measured is worse than one that says "as the crow flies". Metres because kilometres and miles are a display choice. A hotel across three nights is one place with three day assignments, so the place query aggregates back down to one row at its earliest day — otherwise the route would visit the hotel three times. Two test fixes came out of writing this. journey.controller.test.ts still asserted an absolute path from res.sendFile, which the photo fix in this branch had already changed to the basename-plus-root pair; and linkTripToJourney in the factories wrote to linked_at, a column that does not exist — it had no callers, so it had never once run. @ * @ feat(journey): travel elements — the route, the figures, the countries A photo book of a trip is not an album: the trip carries facts, and those facts make pages photographs cannot. Four new elements — a vector route map, a figures panel, a country page and a set of small marks — plus the mood, weather and pros/cons an entry already records. Every one of them carries its own values, resolved when the element is placed and written into the document. The print renderer is headless Chromium loading a page; one that needs an authenticated fetch to know what it says will print blank the day a token expires. It also makes a book a record of a trip as it was, rather than something that quietly rewrites last year distance when a stop is added. travelRefresh compares what is stored against the journey and offers an update, so bringing an element up to date is a choice. The map is vector, from the coordinates, over silhouettes simplified offline from the admin-0 boundaries the server already bundles for Atlas — no licence question, nothing to fetch at render time, sharp at any print size. It paints no background: a map is geography sitting on the paper, not a window cut through it, so it can be laid over a photograph. Flags are drawn rather than typed. A regional-indicator pair is the obvious answer and works everywhere except Chrome on Windows, which has no flag glyphs and renders the two letters instead — "DE" in a printed book is not a flag. Thirty-three flags are exact constructions; anything with an emblem or unequal bands is deliberately absent and falls back to the country outline, because a wrong flag is worse than no flag. Panel tiles render the real element small, through the page own renderer. These differ from each other by what they say rather than by their shape: four map styles are four maps, and no icon can tell you which is which. @ * @ feat(journey): manage spreads from the pages rail Add, insert between two spreads, duplicate, reorder and delete. None of it existed — a book was whatever the auto layout produced. The cover and the back cover are fixed points: a book has exactly one of each, they are single pages, and no sequence of clicks should be able to produce a book with two covers or none. Every operation asks one predicate whether a spread is inner, rather than each caller testing role for itself, and a spread inserted after the back cover lands before it. Duplicating re-ids every element it copies. Two elements sharing an id would make selection ambiguous and every lookup that follows it wrong, delete included. Also: a locked element can be selected again. Locking used to switch its hit target off entirely, which was a dead end — it could not be picked, so the inspector never showed it, so the unlock button was unreachable and the only way back was undo. Locked now means what it says: it cannot be moved, and its resize handles are hidden so the editor does not offer a drag it will refuse. @ * @ feat(journey): let the auto layout use the travel elements The book now opens on a summary spread — the route drawn on the left, the figures on the right — and, for a journey that crossed a border, a country page after it. Both are omitted rather than left empty when there is nothing to draw: a spread holding a blank frame over the words "0 KM" is worse than a shorter book, and every page in a printed one costs money. Only the figures the journey actually has make it onto the page. A zero is not a fact worth a tile, it is a hole in a composition. Each entry gains a short accent rule above its heading — one drawn element, and the cheapest thing here that makes a spread read as designed rather than as text placed on a page. An entry with no photographs, which is the one spread with a blank left page, sets its date as a numeral there: a gap in the book becomes a pause in it. The back cover closed with `${places} Orte` — German, hardcoded, in a book that follows the app language everywhere else. It is a figures element now, so it takes its labels from the same translations as the rest of Studio. The layout waits for the figures rather than racing them. Laying out first would give a journey a summary spread or not depending on which promise resolved first, and Studio already shows its loading state until the document exists, so the wait costs nothing. @ * @ feat(journey): the Studio wordmark replaces the book-name field The field asked for a second name for something already named after the journey — a box whose only real use was being left alone. The bar says what this is instead: the lockup, inline so it takes the theme ink through currentColor rather than needing a light-on-dark copy, and a beta mark beside it. Along with the strings for everything in this branch, across all 23 locales, and the two keys the name field used, removed rather than left as orphans nobody can tell from live ones. journey.studio.frames was "Picture frames" — too long for a tab sharing a row with two others, and untranslated in 21 of the 23 locales, which showed the English through. It is one word now, in every language. @ * @ feat(journey): a free trim size, page numbers, and auto layout as a button Three from the list the PR left open. **Custom trim.** The five presets are what vendors actually bind, which covers almost everyone — "almost" being the problem. A local printer with a 24x18 press, or a book meant for a particular shelf, could not be made here at all. Two fields under the format list, and typing in them *is* picking custom: a book that is 210 by 240 is not square-21 with a typo, so the preset follows rather than needing to be chosen first. Switching to custom keeps the page it is on — it adjusts the format you have. **Page numbers.** Set on the document and drawn by the renderer, which is the only place that knows what number a page is. Stamping them with pdf-lib afterwards would print numbers the editor never showed, and a book you cannot proofread on screen is not much of an editor. They are also the one thing on a page that is not an element: an element holding "14" would still say 14 after its spread was moved. `startAt` exists because the first inner spread is rarely page 1 once bound, and outer/inner is a real distinction on a spread — getting it backwards puts both numbers in the fold. The controls live in the inspector empty state, which was one line of hint text. With nothing selected, that panel is where the book own settings belong. **Auto layout as a button.** It ran once, on open, which made it the one thing in Studio you could not ask for; a page that came out wrong had to be rebuilt by hand. Now it does one spread or the whole book, both as ordinary commits — the first thing anyone does with a button like this is press it to see, and the second is want their page back. A single spread keeps its id so the rail does not scroll, and refuses on anything not built from an entry rather than replacing it with a blank. @ * @ feat(journey): cover layouts, and five more for the spreads The cover had none. The panel said covers are designed by hand, which was true and unhelpful — it is the page people care most about and the one they had no starting point for. A cover is a different problem from a spread and gets its own set rather than a filter over the existing one: it is a single page, the type is the subject rather than a caption to a photograph, and a spread layout applied to it lands half its frames past the edge. Five of them, from full bleed to type-only, and the panel serves the right set for whichever spread is open. The cards scale as one page there, and drop the fold line, since there is no fold. Five more spread layouts as well — a picture with a narrow column of story, nine up, a mosaic around one large frame, two pictures with the words between them, and four staggered so a row of squares stops reading as a contact sheet. Applying a layout also stopped mangling a single text. The old rule took the longest text as the story and looked for a heading among the rest, which reads a three-text spread correctly and gets a one-text cover exactly wrong: the 30pt title *is* the longest text, so it was the story, so a cover layout with a heading slot and no body slot had nowhere to put it and parked the title. Size decides the heading now; length only sorts what is left. @ * @ feat(journey): seven self-hosted book faces, and a picker that shows them The serif slot was Georgia. Georgia is a system font — on most desktops, on no Linux container — so the print renderer would have substituted something else and a book proofread in one typeface would have printed in another. The existing Journey PDF export has exactly this bug today, asking for Inter while only Poppins is bundled. Every family is self-hosted through @fontsource now, all OFL, all loaded with Studio own chunk so a face cannot arrive after the text has been laid out and reflow the line endings someone was setting. Two sans, three serifs from bookish to high-contrast, two display faces for a cover. Every stack ends in a generic rather than in another named font nobody shipped. The three original ids are kept by name, so documents written so far still parse — they point at bundled faces rather than at whatever the machine happened to have. The picker sets each family in itself. A row of chips reading "Poppins / Lora / Bebas" in the interface font tells you their names and nothing about the only thing anyone is choosing between. Weights a family does not ship are disabled rather than offered: asking for bold on a single-weight display face gets a synthesised one, which prints as a smeared regular. Picking such a family moves the weight to the nearest it really has. @ * @ feat(journey): real map imagery, from the providers TREK already has The map element drew country outlines and nothing else. That is still the default and still the right one — nothing fetched, nothing licensed, sharp at any print size. But an outline of Iceland cannot show which road you took, and some books want that. So two more sources, both reading the tile template and Mapbox token an admin already configured for the planner and Atlas. A second place to paste a token is a second place for it to be wrong, and a source with nothing behind it is not offered at all — showing "Mapbox" to someone without a token produces a grey rectangle and no explanation anywhere. Both costs are stated rather than hidden: tiles are fetched when the page renders, and they are raster, cut for one zoom, so printing much larger than they were fetched goes soft. Attribution is neither optional nor a setting. The licence requires credit and a book is a published work — nobody can add a footnote after it is bound. The credit is matched on the host, so an instance running its own OSM mirror still credits OpenStreetMap, and an unrecognised source gets none rather than an invented one. It is written into the document when the element is placed, so the renderer cannot lose it. The route is projected onto the tile grid when there is one. The vector map fits itself to country outlines, and a line placed by that fit sits next to the road rather than on it — the sort of error that only shows up once it is printed. Outlines are dropped under imagery, where they would be a second coastline over the one in the picture. @ * @ fix(journey): make the Studio type-check again The map element used tileView and projectOntoTiles without importing them, useMapSources declared a stricter frame type than any real frame satisfies, and the mood icon read a colour through an `in` check that types as unknown. The page fixtures in the studio tests predate page numbers, so they build a BookPageSetup that the contract no longer accepts — now parsed from the schema, which cannot drift from it. @ * @ feat(journey): the book is saved Studio laid a book out on every open and kept it in the tab. Close the tab and the afternoon went with it, and the auto layout was not a starting point but the only point. The document now lives in journey_books, one row per journey, and autosaves: quiet for a moment after the last change, or at the latest fifteen seconds in, so a drag across a spread is one request rather than four hundred. A stored book wins over a fresh layout on open, and closing the editor writes whatever is still queued. Everyone on a journey can edit its book, so the write carries the version it was made against and the update matches on it in a single statement. A save that has been overtaken is refused with the current record in the body, which turns "your work vanished" into a choice between two versions. The server broadcasts journey:book:saved to the other editors — the version, not the document — and a client with nothing outstanding takes the new one. A client with unsaved edits deliberately takes nothing and conflicts on its next save instead. @ * @ feat(journey): export the book The one thing Studio could not do. The button was there and disabled. A book now goes out as sheets: every inner spread cut down the middle into leaves in reading order, bleed carried out past the trim on all four sides, crop marks outside that saying where to cut. The cut is a window rather than a re-layout, so a photograph crossing the gutter still meets itself once both halves are printed. Spreads stay whole in the other mode, which is what you send someone to read. The browser makes the PDF, from the editor components at 1:1. Considered and rejected: headless Chromium on the server, and a PDF library drawing the pages a second time. TREK is self-hosted, and neither Chromium in the image nor a second renderer to proofread against is worth carrying when the browser already produces exactly what the editor shows — it is also how the other exports here work. What the browser will not write is PDF metadata and the TrimBox entries; the marks carry that instead, as they have since long before the format existed. Two things had to give. `SpreadView` clipped at the trim, which is right on screen and leaves a white hairline down every edge of a bound book, so print mode lets the page run out and the sheet does the clipping. And the print document is its own document: it carries the app stylesheets across so the seven bundled faces are the ones that print, and waits for images and fonts before it offers to save. @ * @ fix(journey): make the Studio popovers cover what they cover The auto-layout menu sat on the glass tokens, which carry 42-58% opacity when the user has transparency on. That reads as depth over the workbench, where the rails and the bar use it, and as a mess over the inspector, where this menu opens: two layers of text through each other, neither readable. The export dialog was worse — it named --panel and --card, which are not tokens here, so its background resolved to nothing at all and the whole thing was see-through. Both are opaque now. The other glass surfaces are left alone: they float over the workbench, and that is what the material is for. The print preview also fits the pane. A spread sheet is over 400 mm across, so at 1:1 the book ran off the side and had to be read by scrolling sideways. Screen only — the print stays at full size. @ * fix(journey): a cover keeps its own size, and a trim size can be typed Three things the export and the page setup got wrong. Spread mode made every sheet as wide as the widest so that one @page rule would cover them all. That put a cover — one page — in the middle of a spread-sized sheet with white either side, which is not a cover. Each sheet is now sized to what is on it, and the print CSS carries a named page rule for the single-page ones. The trim fields clamped on every keystroke, so typing 500 meant the field saw 5, clamped it to the minimum and wrote that back under the cursor: the number could not be typed at all, only nudged with the arrows. The field now keeps what is being typed and tells the document about values it can use. The arrows are unchanged, and leaving the field still clamps — someone who typed 5 and clicked away meant the smallest book. And the autosave could loop. The save effect depended on the whole store object, which is new on every render, so after a conflict — where the local document is by definition not the saved one — it was a save attempt per render: a 409, a re-render, another attempt. It depends on the callbacks now, and a conflict holds the autosave until somebody answers it. * feat(journey): page numbers pick their own colour A book has dark pages and light ones, often on the same spread, so one folio colour is wrong on half of them: the warm grey that reads on paper vanishes into a full-bleed night photograph, and white vanishes the moment the page is white. The way out used to be setting it per spread, which the document has no place for. The renderer asks what is under the number instead. It knows every element and where it sits, so it walks them from the top down and takes the first one that covers the spot: a shape's fill is arithmetic, a photograph gets white with a hairline of shadow — unknowable without decoding it, and dark more often than not once printed — and bare page takes the page colour. A heuristic, deliberately: the alternative is sampling pixels from an image that may not have loaded, in a renderer that also has to run for print. Anyone it gets wrong can still pick a colour, and picking one turns it off. * feat(journey): see who else is in the book, and where they are Two people editing the same document without seeing each other is how you get two people editing the same spread. So: a badge in the bar saying who has the book open, and an arrow with a name on it for each of them. The coordinates are millimetres on the spread, not pixels on a screen — the two of you are at different zoom levels on different monitors, and a pixel means nothing to the other one. They carry a spread index too, so a pointer on page 40 is not drawn on page 2. Ephemeral throughout. Nothing is written down, the room is the authorisation for the pointer path, and a socket that dies takes its arrow with it rather than leaving it on everyone else's page. Presence is keyed by socket because two tabs are two pointers; the badge folds them back into one person, because two tabs are not two people. The rate limit needed room. Pointers move about ten times a second, three times what the per-socket limit allows, and that limit is what stops a client flooding the process — so it is now two: an outer ceiling every frame counts against before parsing, and the original tight limit on everything a pointer is not. Malformed JSON still counts against the tight one, which is what the guard was for. Throttled to ten frames a second going out and interpolated coming in: sixty would be five times the traffic for something the eye cannot use, and drawing ten as they land reads as a stutter rather than a hand. * fix(journey): keep the zoom bar on screen at every zoom level Zoomed far enough in, the bar slid off the bottom of the window — and it is the only way back out, so the editor was stuck at 300%. The workbench sat in a grid track sized `auto`, which grows to whatever is in it; `overflow: auto` on the child does not stop that, because a grid item's minimum size is its content unless told otherwise. So at 300% the box grew to 2400px inside a 660px window and took its absolutely positioned bottom edge with it. The track is bound now, and the workbench scrolls instead of growing. The bar also has a z-index, for the same reason it has to stay in view: it must never be the thing something else covers. * feat(journey): Studio replaces the old book export The journey PDF and Studio were two ways to print the same book that disagreed about what it looks like — and only one of them can be edited. The old export goes: the two buttons on the desktop already sat next to the Studio one, and the renderer they called is deleted rather than left as a second answer. Nothing takes its place on the phone. The book is a Studio document now and Studio is a desktop editor; a phone-sized PDF of something laid out for print was the old export's answer to a question nobody was asking. * feat(journey): lay the book out the way a travel book is laid out The auto layout built every spread the same way: a picture on the left, words on the right, and nothing else. It came out looking like a template because it was one — and on a journey with little in it, like a stack of blank paper. Five layouts now, chosen by what an entry has and alternating as the book goes on. A place page for a stop nobody wrote about, with the country pale behind the name and the coordinates under it. A story page with a small map of the stop and the picture bleeding off three edges. A panorama across the fold. A gallery with the hero on one page and a ruled strip on the other. And every fourth picture spread goes full bleed with the words set over it, because a book of loud pages has no loud pages. The marks are what actually make these pages: a flag beside the country, a date numeral, a chip saying which day this is, a rule along the foot with a dot showing how far in you are. Each is trivial and together they are the difference between a page that was designed and one that was filled in. All of them are elements the panels can make, so what comes out can be taken apart. The summary spread gained a heading and the furthest point drawn as two dots and a line — that figure means nothing until it is a distance between two names. The countries page went dark, because the printed one is: pale outlines on near-black is the single most recognisable page in these books. Two things the old layout got wrong are fixed on the way. Entries with nothing on them share one page instead of taking a spread each, in the order they happened rather than gathered at the end. And an entry whose photographs have not arrived gets the frames they will go in, empty, rather than blank paper — the layout is finished and the pictures are the only thing missing, which is the right way round. * feat(journey): build the spreads out of areas, and set the elements in ink Two things, both about what a page is made of. The layouts were photographs with captions. A printed travel book is built out of areas: a page laid solid in ink with pale type on it, a band down the outer edge, a picture that overruns the panel it sits against, a box ruled around a block of text. Two more layouts do that now — one with a whole page in ink and a second photograph crossing the fold onto it, one with an edge band and the words in a ruled box, pictures at two sizes and one overlapping the hero. Six layouts on a rotation, so the book stops reading as a template. None of it needed a new feature. The document paints in array order, so a panel pushed first is a panel everything else lands on; that is all layering is, and it needed using rather than adding. And the travel elements are ink by default rather than the app's orange. A book is printed and a printed page is ink on paper; a colour that arrives without being chosen turns every element into the same accent whether the book wants one or not. Picking one is a click. The dark map is the exception — the route goes pale there, because ink on a near-black country is a line nobody can see, and only the default is overridden: a colour somebody chose stays theirs. * fix(journey): three things the editor got wrong An empty photo frame rendered on its own, ignoring frameStyle entirely — so a Polaroid placeholder was a plain dashed rectangle until a photograph landed in it and the chin appeared from nowhere. The two branches are folded together now: the surround is drawn once and only what goes inside it differs, so a frame shows what it will look like before it is filled. Adding a page to a book with no inner pages yet put it in front of the cover, where it could not be moved back from either — a move only ever swaps with another inner spread, and the cover is not one. An empty book reports its last inner spread as -1, and -1 + 1 is 0. The insert is bounded at both ends now. And snapping only knew the outer safe lines. A spread draws that dashed rule inset from each of its four vertical edges, so dragging inwards caught the fold and nothing else — the line you could see just before it was not a target. All four are, along with the centre of each page and the quarters within it: on a spread the halfway mark is the fold, so a picture centred on its own page had nothing to find. * feat(journey): take the auto layout's spreads from templates drawn by hand The layouts written as code 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 a corner, a strip of pictures at two sizes with the coordinates set beneath them. Those decisions come from looking at a page, and the editor is where looking at a page happens. So a spread built in Studio can now be the layout. `extract-templates.cjs` reads a journey's book and writes its spreads out as data, with every measurement divided by the page it was drawn on — a template built on a 210mm square lays out on an A4 landscape book without being redrawn. `applyTemplate` does the other half: it fills what the entry can answer for and copies everything else as drawn. That division is the whole contract. Text carrying a binding takes the title or the story; empty frames take the photographs in order; the day, coordinate, date and country marks take what the stop knows. Panels, rules and decorative shapes are the design and are left exactly as they were put. A template is one *for an entry* when it uses one — which has to come out of the drawing rather than a label somebody remembers to set. A summary spread of figures and country outlines reads perfectly well on its own, and dropping a day's photographs into it would be nonsense. Two templates ship with this, drawn by the person who will use them. The built-in layouts stay as the fallback for entries no template suits. * feat(journey): turn elements freely, and pass a spread on as a file Rotation by the corner, the way it works everywhere else: a handle sits diagonally outside each of the four corners of the selection, and dragging one turns the element by the change in angle rather than to the angle under the pointer, so it can be picked up anywhere without jumping. Held to 15 degree steps unless shift is down. Four handles rather than one because a single handle off the top right sits under the inspector for anything in the right page's upper half. And a spread can now leave the book it was made in. The zoom bar downloads the one on screen as a file; Import, beside Add spread in the pages rail, brings one back. Measurements travel as fractions of the page, so a spread drawn on a 210mm square lays out correctly in an A4 landscape book, the same way the auto layout's templates do. What deliberately does not travel: - Photographs. The file carries the design, and the frames arrive empty. - The map source. A static map freezes the URL it was rendered from, and on a Mapbox instance that URL has the access token in it, so a file meant to be posted in a forum would carry a credential. Coming the other way, a tile template from a stranger is a URL every reader's browser would fetch tile by tile: an IP address handed to whoever wrote the file. An imported file is treated as what it is, somebody else's JSON. It is read in the tab that chose it and never uploaded, parsed and never evaluated, and every element goes through the document contract, which drops unknown fields and range-checks the rest. Colours in particular are #rrggbb or nothing. The size of the file and the number of elements are both capped, because the damage a hostile file can do here is not code execution, it is handing the editor more than the server will accept and leaving a document that will not save. Also fixes the rotation handle being drawn but not grabbable: the chrome layer and the selection box both pass pointers through, and a handle has to opt back in with pointer-events. * docs(wiki): add two Studio screenshots for the addon page The editor with a stats spread open, and the frames panel. Both are what the Journey addon page will want once Studio ships, and both are wanted now to show the feature off while it is still in review. * test(realtime): cover the book room's refusals and its dead sockets The realtime coverage ratchet caught what the book room work left behind: the three handlers each have an early return for a message that is missing its journey id or comes from a socket with no user, and none of them was exercised. Every one of those is a silent failure by construction. A handler that did something with a missing journey id would not throw, it would put a socket somewhere it does not belong, and nobody would notice until two strangers were looking at the same page. The other half is sockets that are in a room but cannot be used: a connection that dropped before its close handler ran, or one put in a room without ever being registered. The peer list has to walk past both rather than showing a ghost, and the broadcast has to skip them rather than throwing on a path that runs ten times a second per editor. Both files are back to full branch coverage, so the domain's pins hold as they are rather than being lowered to fit. * feat(journey): make the route map a map of the journey Four things were wrong with it, and they compounded. **It showed the wrong place.** The view was fitted to the country outlines as well as the stops, so a trip that stayed inside Berlin was drawn as the whole of Germany with two dots in the middle of it. It fits what the journey did now, and the outlines run off the edge of the frame the way a coastline does on any real map. Fitting to the country is still there as a deliberate choice, for the page every travel book has, and how much air to leave around either is a setting rather than a constant. **It drew a line nobody travelled.** Stops joined in order is a claim about the journey that is almost always false: it crosses mountains, water and borders the trip went around. TREK already stores routed geometry per place, from the planner and from imported GPX, so the map now follows the road where the trip knows it and falls back to joining the stops where it does not. Segments stay separate, so a trip with a flight in the middle does not grow a road across the sea. The geometry is thinned when it is frozen into the element: a printed line gains nothing from a point every fifteen metres, and a book carrying every GPS fix of a three-week trip is a document nobody can save. **The tiled and static sources drew nothing at all.** The tile grid was computed, the route was projected onto it, and then no picture was ever rendered — the map came out as a frame with a line across it. Three separate faults behind that: the imagery layer did not exist, `{s}` was left unsubstituted so every tile URL named a host that does not resolve, and the grid was cut from the tiles the route happened to sit on rather than from what the frame shows, so it stopped short of the edge. The frame decides the grid now, and it covers by construction. Tiles overlap by a tenth of a millimetre, which closes the hairline of white paper that rounding left between them. Mapbox needed one more thing: `mapbox/standard` is a 3D GL style and the Static Images API refuses it outright, so an instance configured for the planner's map was configured with a style this cannot use. It falls back to the classic equivalent, and a URL frozen into a document before that existed is repaired on the way out rather than left broken for good. **A map was always a rectangle.** It can be cut to the outline of the countries now, so the picture *is* the country: no frame, no box, the page showing through around the coastline. That is the version worth laying next to a photograph, and the one thing a rectangle can never be. The tile grid and the vector fit are projected from one extent so the picture and the shape it is cut to line up — two fits meant the country was full of the wrong towns. Also here: the credit line comes off the map itself. It is still resolved and still stored, for the colophon, which is where a book has always credited its sources. * feat(journey): split the country list, and trim the map cards The country list and the country grid were compositions somebody else made: they decided the order, the spacing and the type size, and the only way to change any of it was to not use them. They are gone, and what is left is the same entry — the name over its silhouette, set the same way — as an element placed one country at a time. Three down a page is the list, with the spacing yours; one at 80mm is a chapter opener, which the list could never be. The name is editable, so numbering them "1. Germany", "2. Netherlands" is typing rather than a feature. The compact mark, outline beside the name at the size of a line of type, stays for the places that want a mark rather than a heading. The map section offers the four vector styles and nothing else. Tiles, Mapbox and the cut-out are one click away in the inspector, where the rest of the map's settings already live, and four cards that are the same map four times were crowding out everything under them. A map is fitted to the country by default now, which is the picture a travel book usually wants. Fitting to the stops is still there and still matters the moment the trip is smaller than the country it happened in. Also: no navigation fallback from the dev service worker. In production it is right — offline, a deep link has to be answered by the cached shell. In development it answers a reload with the index.html it cached earlier, whose script tags point at module URLs from before the last restart, so the browser re-runs the previous version of the app while the editor shows the new one. Every explanation for that is wrong until someone thinks of the service worker. * fix(journey): drop what the trimmed map section left behind Three bindings that only fed the cards removed in the commit before: the map sources hook, the country codes the cut-out card needed, and the frame the preview sized itself from. Lint had them as warnings rather than errors, so they would have ridden along quietly. --- client/package.json | 5 + client/scripts/build-country-shapes.mjs | 278 +++ client/src/App.tsx | 8 +- client/src/api/client.ts | 15 + client/src/api/websocket.ts | 49 + client/src/api/wsEventPolicy.ts | 8 + .../components/PDF/JourneyBookPDF.test.tsx | 247 --- client/src/components/PDF/JourneyBookPDF.tsx | 391 ---- .../src/components/Studio/BookSheetsView.tsx | 176 ++ client/src/components/Studio/PageNumbers.tsx | 98 + client/src/components/Studio/PeerBadges.tsx | 52 + client/src/components/Studio/PeerCursors.tsx | 116 ++ .../src/components/Studio/SaveIndicator.tsx | 74 + client/src/components/Studio/SpreadView.tsx | 523 +++++ client/src/components/Studio/StudioCanvas.tsx | 453 +++++ .../components/Studio/StudioElementsPanel.tsx | 325 +++ client/src/components/Studio/StudioExport.tsx | 194 ++ .../src/components/Studio/StudioInspector.tsx | 515 +++++ .../src/components/Studio/StudioPanelHead.tsx | 15 + client/src/components/Studio/StudioShell.tsx | 437 +++++ .../src/components/Studio/StudioSidebar.tsx | 610 ++++++ .../src/components/Studio/StudioSwatches.tsx | 37 + .../Studio/StudioTravelInspector.tsx | 543 +++++ .../components/Studio/StudioTravelPanel.tsx | 372 ++++ .../src/components/Studio/StudioWordmark.tsx | 24 + .../src/components/Studio/TravelElements.tsx | 1117 +++++++++++ .../src/components/Studio/TravelPreview.tsx | 74 + client/src/components/Studio/TrimField.tsx | 66 + client/src/components/Studio/applyTemplate.ts | 238 +++ client/src/components/Studio/autoLayout.ts | 1581 +++++++++++++++ client/src/components/Studio/bookFonts.ts | 141 ++ client/src/components/Studio/bookRender.ts | 25 + client/src/components/Studio/bookSheets.ts | 159 ++ .../components/Studio/bookTemplates.data.ts | 553 ++++++ client/src/components/Studio/countryShapes.ts | 267 +++ client/src/components/Studio/flags.ts | 162 ++ client/src/components/Studio/folioColour.ts | 92 + client/src/components/Studio/grids.ts | 226 +++ client/src/components/Studio/mapSources.ts | 64 + client/src/components/Studio/mapTiles.ts | 316 +++ client/src/components/Studio/pagePresets.ts | 92 + client/src/components/Studio/peerColour.ts | 22 + client/src/components/Studio/printSheets.ts | 269 +++ client/src/components/Studio/shapes.ts | 500 +++++ client/src/components/Studio/spreadFile.ts | 219 +++ client/src/components/Studio/templates.ts | 447 +++++ client/src/components/Studio/travelRefresh.ts | 103 + .../src/components/Studio/useBookPresence.ts | 160 ++ client/src/components/Studio/useBookStore.ts | 292 +++ .../components/Studio/useSpreadInteraction.ts | 357 ++++ .../mobile/screens/journey/MJourneyDetail.tsx | 20 +- client/src/pages/JourneyDetailPage.tsx | 37 +- .../pages/JourneyDetailPage.wiring.test.tsx | 38 +- client/src/pages/JourneyStudioPage.tsx | 14 + .../pages/journeyDetail/useJourneyDetail.ts | 29 + .../pages/journeyStudio/useJourneyStudio.ts | 621 ++++++ client/src/store/studioStore.ts | 333 ++++ client/src/styles/studio.css | 1743 +++++++++++++++++ .../mobile/journey/MJourneyDetail.test.tsx | 23 +- .../tests/unit/studio/BookSheetsView.test.tsx | 225 +++ client/tests/unit/studio/PeerCursors.test.tsx | 119 ++ .../tests/unit/studio/SaveIndicator.test.tsx | 101 + client/tests/unit/studio/SpreadView.test.tsx | 390 ++++ .../tests/unit/studio/StudioExport.test.tsx | 143 ++ .../tests/unit/studio/TravelPreview.test.tsx | 131 ++ .../tests/unit/studio/applyTemplate.test.ts | 174 ++ .../unit/studio/autoLayout.stations.test.ts | 173 ++ .../unit/studio/autoLayout.travel.test.ts | 279 +++ client/tests/unit/studio/bookFonts.test.ts | 107 + client/tests/unit/studio/bookSheets.test.ts | 119 ++ client/tests/unit/studio/entryExtras.test.tsx | 186 ++ client/tests/unit/studio/flags.test.ts | 171 ++ client/tests/unit/studio/folioColour.test.ts | 133 ++ client/tests/unit/studio/grids.test.ts | 178 ++ .../tests/unit/studio/lockedElement.test.tsx | 126 ++ client/tests/unit/studio/mapFraming.test.tsx | 208 ++ client/tests/unit/studio/mapTiles.test.ts | 179 ++ client/tests/unit/studio/pageSetup.test.tsx | 162 ++ client/tests/unit/studio/printSheets.test.ts | 285 +++ client/tests/unit/studio/routeInk.test.tsx | 62 + client/tests/unit/studio/shapes.test.ts | 319 +++ client/tests/unit/studio/spreadFile.test.ts | 156 ++ client/tests/unit/studio/spreadFixes.test.tsx | 172 ++ .../unit/studio/studioStore.spreads.test.ts | 313 +++ client/tests/unit/studio/templates.test.ts | 150 ++ .../tests/unit/studio/travelRefresh.test.ts | 208 ++ client/tests/unit/studio/trimField.test.tsx | 107 + .../unit/studio/useBookPresence.test.tsx | 191 ++ .../tests/unit/studio/useBookStore.test.tsx | 517 +++++ client/vite.config.js | 31 + package-lock.json | 119 +- server/scripts/extract-templates.cjs | 109 ++ server/src/db/migrations.ts | 30 + .../src/nest/journey/journey-book.service.ts | 218 +++ .../nest/journey/journey-domain.service.ts | 179 +- .../nest/journey/journey-public.controller.ts | 4 +- server/src/nest/journey/journey-stats.ts | 205 ++ server/src/nest/journey/journey.controller.ts | 84 + server/src/nest/journey/journey.dto.ts | 4 + server/src/nest/journey/journey.module.ts | 3 +- server/src/nest/journey/journey.service.ts | 1 + .../nest/memories/photo-resolver.service.ts | 9 +- .../nest/memories/trek-photo-cache.service.ts | 4 +- .../nest/realtime/realtime-gateway.module.ts | 5 +- server/src/nest/realtime/realtime.gateway.ts | 117 ++ server/src/nest/realtime/trek-ws.adapter.ts | 79 +- server/src/nest/realtime/ws-state.ts | 92 + server/tests/e2e/journey.e2e.test.ts | 185 +- server/tests/helpers/factories.ts | 4 +- server/tests/helpers/test-db.ts | 1 + .../integration/leg-mode-incoming.test.ts | 23 +- .../unit/nest/journey-book.service.test.ts | 334 ++++ .../unit/nest/journey-stats.service.test.ts | 358 ++++ server/tests/unit/nest/journey-stats.test.ts | 364 ++++ .../unit/nest/journey.controller.test.ts | 257 ++- .../tests/unit/nest/realtime.gateway.test.ts | 315 ++- .../tests/unit/nest/trek-ws-adapter.test.ts | 46 + shared/src/book/book-store.schema.ts | 69 + shared/src/book/book.schema.ts | 568 ++++++ shared/src/book/journey-stats.schema.ts | 76 + shared/src/i18n/ar/journey.ts | 272 +++ shared/src/i18n/br/journey.ts | 272 +++ shared/src/i18n/ca/journey.ts | 272 +++ shared/src/i18n/cs/journey.ts | 272 +++ shared/src/i18n/de/journey.ts | 272 +++ shared/src/i18n/en/journey.ts | 272 +++ shared/src/i18n/es/journey.ts | 272 +++ shared/src/i18n/fr/journey.ts | 272 +++ shared/src/i18n/gr/journey.ts | 272 +++ shared/src/i18n/hu/journey.ts | 272 +++ shared/src/i18n/id/journey.ts | 272 +++ shared/src/i18n/it/journey.ts | 272 +++ shared/src/i18n/ja/journey.ts | 272 +++ shared/src/i18n/ko/journey.ts | 272 +++ shared/src/i18n/nl/journey.ts | 272 +++ shared/src/i18n/pl/journey.ts | 272 +++ shared/src/i18n/ru/journey.ts | 272 +++ shared/src/i18n/sv/journey.ts | 272 +++ shared/src/i18n/tr/journey.ts | 272 +++ shared/src/i18n/uk/journey.ts | 272 +++ shared/src/i18n/vi/journey.ts | 272 +++ shared/src/i18n/zh-TW/journey.ts | 272 +++ shared/src/i18n/zh/journey.ts | 272 +++ shared/src/index.ts | 3 + shared/src/realtime/events.schema.spec.ts | 15 +- shared/src/realtime/events.schema.ts | 58 + wiki/assets/TREK-Studio-Editor.png | Bin 0 -> 1715857 bytes wiki/assets/TREK-Studio-Frames.png | Bin 0 -> 1547895 bytes 148 files changed, 31119 insertions(+), 855 deletions(-) create mode 100644 client/scripts/build-country-shapes.mjs delete mode 100644 client/src/components/PDF/JourneyBookPDF.test.tsx delete mode 100644 client/src/components/PDF/JourneyBookPDF.tsx create mode 100644 client/src/components/Studio/BookSheetsView.tsx create mode 100644 client/src/components/Studio/PageNumbers.tsx create mode 100644 client/src/components/Studio/PeerBadges.tsx create mode 100644 client/src/components/Studio/PeerCursors.tsx create mode 100644 client/src/components/Studio/SaveIndicator.tsx create mode 100644 client/src/components/Studio/SpreadView.tsx create mode 100644 client/src/components/Studio/StudioCanvas.tsx create mode 100644 client/src/components/Studio/StudioElementsPanel.tsx create mode 100644 client/src/components/Studio/StudioExport.tsx create mode 100644 client/src/components/Studio/StudioInspector.tsx create mode 100644 client/src/components/Studio/StudioPanelHead.tsx create mode 100644 client/src/components/Studio/StudioShell.tsx create mode 100644 client/src/components/Studio/StudioSidebar.tsx create mode 100644 client/src/components/Studio/StudioSwatches.tsx create mode 100644 client/src/components/Studio/StudioTravelInspector.tsx create mode 100644 client/src/components/Studio/StudioTravelPanel.tsx create mode 100644 client/src/components/Studio/StudioWordmark.tsx create mode 100644 client/src/components/Studio/TravelElements.tsx create mode 100644 client/src/components/Studio/TravelPreview.tsx create mode 100644 client/src/components/Studio/TrimField.tsx create mode 100644 client/src/components/Studio/applyTemplate.ts create mode 100644 client/src/components/Studio/autoLayout.ts create mode 100644 client/src/components/Studio/bookFonts.ts create mode 100644 client/src/components/Studio/bookRender.ts create mode 100644 client/src/components/Studio/bookSheets.ts create mode 100644 client/src/components/Studio/bookTemplates.data.ts create mode 100644 client/src/components/Studio/countryShapes.ts create mode 100644 client/src/components/Studio/flags.ts create mode 100644 client/src/components/Studio/folioColour.ts create mode 100644 client/src/components/Studio/grids.ts create mode 100644 client/src/components/Studio/mapSources.ts create mode 100644 client/src/components/Studio/mapTiles.ts create mode 100644 client/src/components/Studio/pagePresets.ts create mode 100644 client/src/components/Studio/peerColour.ts create mode 100644 client/src/components/Studio/printSheets.ts create mode 100644 client/src/components/Studio/shapes.ts create mode 100644 client/src/components/Studio/spreadFile.ts create mode 100644 client/src/components/Studio/templates.ts create mode 100644 client/src/components/Studio/travelRefresh.ts create mode 100644 client/src/components/Studio/useBookPresence.ts create mode 100644 client/src/components/Studio/useBookStore.ts create mode 100644 client/src/components/Studio/useSpreadInteraction.ts create mode 100644 client/src/pages/JourneyStudioPage.tsx create mode 100644 client/src/pages/journeyStudio/useJourneyStudio.ts create mode 100644 client/src/store/studioStore.ts create mode 100644 client/src/styles/studio.css create mode 100644 client/tests/unit/studio/BookSheetsView.test.tsx create mode 100644 client/tests/unit/studio/PeerCursors.test.tsx create mode 100644 client/tests/unit/studio/SaveIndicator.test.tsx create mode 100644 client/tests/unit/studio/SpreadView.test.tsx create mode 100644 client/tests/unit/studio/StudioExport.test.tsx create mode 100644 client/tests/unit/studio/TravelPreview.test.tsx create mode 100644 client/tests/unit/studio/applyTemplate.test.ts create mode 100644 client/tests/unit/studio/autoLayout.stations.test.ts create mode 100644 client/tests/unit/studio/autoLayout.travel.test.ts create mode 100644 client/tests/unit/studio/bookFonts.test.ts create mode 100644 client/tests/unit/studio/bookSheets.test.ts create mode 100644 client/tests/unit/studio/entryExtras.test.tsx create mode 100644 client/tests/unit/studio/flags.test.ts create mode 100644 client/tests/unit/studio/folioColour.test.ts create mode 100644 client/tests/unit/studio/grids.test.ts create mode 100644 client/tests/unit/studio/lockedElement.test.tsx create mode 100644 client/tests/unit/studio/mapFraming.test.tsx create mode 100644 client/tests/unit/studio/mapTiles.test.ts create mode 100644 client/tests/unit/studio/pageSetup.test.tsx create mode 100644 client/tests/unit/studio/printSheets.test.ts create mode 100644 client/tests/unit/studio/routeInk.test.tsx create mode 100644 client/tests/unit/studio/shapes.test.ts create mode 100644 client/tests/unit/studio/spreadFile.test.ts create mode 100644 client/tests/unit/studio/spreadFixes.test.tsx create mode 100644 client/tests/unit/studio/studioStore.spreads.test.ts create mode 100644 client/tests/unit/studio/templates.test.ts create mode 100644 client/tests/unit/studio/travelRefresh.test.ts create mode 100644 client/tests/unit/studio/trimField.test.tsx create mode 100644 client/tests/unit/studio/useBookPresence.test.tsx create mode 100644 client/tests/unit/studio/useBookStore.test.tsx create mode 100644 server/scripts/extract-templates.cjs create mode 100644 server/src/nest/journey/journey-book.service.ts create mode 100644 server/src/nest/journey/journey-stats.ts create mode 100644 server/tests/unit/nest/journey-book.service.test.ts create mode 100644 server/tests/unit/nest/journey-stats.service.test.ts create mode 100644 server/tests/unit/nest/journey-stats.test.ts create mode 100644 shared/src/book/book-store.schema.ts create mode 100644 shared/src/book/book.schema.ts create mode 100644 shared/src/book/journey-stats.schema.ts create mode 100644 wiki/assets/TREK-Studio-Editor.png create mode 100644 wiki/assets/TREK-Studio-Frames.png 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 ( +