Compare commits

..

1 Commits

Author SHA1 Message Date
Maurice 19ae607f54 feat(assistant): the assistant opens outside a trip, as a home chat about all trips
A third scope, home, next to trip and journey: threads that name neither a
trip nor a journey (the CHECK now only forbids both), reached under
/api/assistant by their owner only. A home turn reads the user's trips
through list_trips into a short card (under way, next, later, behind,
undated), offers every read (a trip's on the id the model names, checked by
the tool with the user's rights) and only the writes that take no trip or
journey (atlas, vacation planner, collections, tour library, settings), all
held as change cards. open_trip_chat answers with a card that goes on in
that trip's own chat with the question waiting in its composer.

On the desktop the window opens on the home chat outside a trip; its head
lists the trips to switch into and a switched trip chat has the way back.
On the phone the home chat opens full screen from the dashboard head and the
dock's More list, and the card opens the trip with its chat. Both run on
useAssistantHome.
2026-10-23 21:19:25 +02:00
919 changed files with 3438 additions and 67884 deletions
-51
View File
@@ -53,54 +53,3 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
## Typefaces: Studio books and the app
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
copyright notice of the family's authors in its `LICENSE` file.
Both licences allow a font to be bundled with software and embedded in a
document made with it, so a printed or exported book may carry the faces it is
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
https://www.apache.org/licenses/LICENSE-2.0
| Family | Package | Licence |
|---|---|---|
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
-3
View File
@@ -112,9 +112,6 @@ data:
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }}
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
{{- end }}
{{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }}
-1
View File
@@ -108,7 +108,6 @@ env:
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
# TREK_PLACES_URL: ""
# GOOGLE_LIST_DATA_BASE: ""
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com.
-67
View File
@@ -1,67 +0,0 @@
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
/**
* Which font files the service worker installs with the app.
*
* The precache takes every woff and woff2 the build emits, and with the book's
* catalogue at thirty four families that is every weight, both styles and
* every script subset of each: megabytes fetched at install by every user,
* most of it for faces no book of theirs is set in. So the book's faces are
* held to this:
*
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
* imported in src/main.tsx) stay exactly as they were.
* - The faces the book shipped with before the catalogue grew
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
* still opens offline before Studio was ever opened online.
* - Every other file of a book family, woff, other subsets, new families,
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
* which the Studio fills with the faces of each book it opens
* (src/components/Studio/bookFontWarm.ts).
*
* Shared by vite.config.js and its test, which reads the filter against file
* names as the build writes them.
*/
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
export const APP_FACES = {
poppins: [300, 400, 500, 600, 700],
museomoderno: [400, 700, 800],
};
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
(a, b) => b.length - a.length
);
const PRECACHED = new Map(
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
);
/**
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
* read against the book's packages; null for any other file.
*/
export function bookFontFile(url) {
const name = url.split('/').pop() ?? '';
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
if (!match) return null;
const [, head, weight, style, ext] = match;
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
if (!pkg) return null;
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
}
/** Whether a file of the build goes into the install-time precache. */
export function keepInPrecache(url) {
const file = bookFontFile(url);
if (!file) return true;
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
const kept = PRECACHED.get(file.pkg);
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
}
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
export function withoutRuntimeFonts(entries) {
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
}
+1 -28
View File
@@ -47,41 +47,14 @@
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
@@ -108,7 +81,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"tz-lookup": "^6.1.25",
"zod": "^4.6.7",
"zod": "^4.6.5",
"zustand": "^5.0.15"
},
"devDependencies": {
+8 -8
View File
@@ -1,18 +1,18 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 401,
"@typescript-eslint/no-misused-promises": 850,
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 854,
"no-console": 88,
"complexity": 226,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130,
"trek/no-swallowed-catch": 131,
"max-params": 33,
"trek/store-without-selector": 67,
"react-hooks/refs": 556,
"react-hooks/set-state-in-effect": 194,
"react-hooks/refs": 557,
"react-hooks/set-state-in-effect": 195,
"@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 125,
"@typescript-eslint/no-unused-vars": 126,
"react-hooks/immutability": 28,
"max-lines-per-function": 132,
"react-hooks/static-components": 78,
@@ -20,7 +20,7 @@
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 45,
"react-refresh/only-export-components": 46,
"unused-disable-directive": 6,
"max-depth": 21,
"react-hooks/void-use-memo": 1,
@@ -34,7 +34,7 @@
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1932,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
+1
View File
@@ -601,6 +601,7 @@
"hooks": ["src/components/Planner/usePlaceActions.ts"],
"desktop": [
"src/components/Planner/PlaceInspector.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/pages/tripPlanner/useTripPlannerPage.ts"
],
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
+2
View File
@@ -1123,6 +1123,8 @@
"src/components/shared/PasswordChecklist.tsx",
"src/components/shared/PlaceAvatar.test.tsx",
"src/components/shared/PlaceAvatar.tsx",
"src/components/shared/PlaceAvatarUpload.test.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/components/shared/PluginIcon.test.tsx",
"src/components/shared/PluginIcon.tsx",
"src/components/shared/PublicLanguagePicker.test.tsx",
-7
View File
@@ -144,13 +144,6 @@ export const DYNAMIC_ALLOWED = [
'memories.* key in en',
}))
),
{
file: 'components/Studio/stickerLibrary.ts',
template: 'journey.studio.sticker.${id}',
because:
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
'sticker the library holds against en',
},
];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+1 -1
View File
@@ -26,7 +26,7 @@
"packing.ts": 6,
"pdf.ts": 1,
"perm.ts": 4,
"places.ts": 9,
"places.ts": 10,
"planner.ts": 53,
"reservations.ts": 21,
"roadtrip.ts": 36,
+1
View File
@@ -116,6 +116,7 @@
"src/components/Vacay/useVacayCalendarLogic.ts",
"src/components/Vacay/useVacaySettings.ts",
"src/components/Vacay/useVacayUserPicker.ts",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/hooks/useAirtrailConnection.ts",
"src/hooks/useDawarichConnection.ts",
"src/hooks/useDawarichSuggestions.ts",
+4 -3
View File
@@ -18,6 +18,7 @@
"components/Journey/JourneyMap.tsx": 1,
"components/Journey/JourneyMapGL.tsx": 2,
"components/Journey/MobileEntryView.tsx": 1,
"components/Journey/PhotoLightbox.tsx": 3,
"components/Journey/journeyPhotoLayer.ts": 1,
"components/Layout/Navbar.tsx": 1,
"components/Layout/OfflineBanner.tsx": 1,
@@ -51,9 +52,8 @@
"components/Studio/BookSheetsView.tsx": 3,
"components/Studio/PageNumbers.tsx": 1,
"components/Studio/PeerCursors.tsx": 1,
"components/Studio/PhotoView.tsx": 4,
"components/Studio/SpreadView.tsx": 4,
"components/Studio/StudioCanvas.tsx": 12,
"components/Studio/SpreadView.tsx": 8,
"components/Studio/StudioCanvas.tsx": 22,
"components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1,
@@ -73,6 +73,7 @@
"components/shared/DetailShell.tsx": 1,
"components/shared/ElevationProfile.tsx": 2,
"components/shared/NavigationMenu.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 1,
"components/shared/SlidingTabs.tsx": 3,
"components/shared/StarRating.tsx": 3,
+2 -4
View File
@@ -1,6 +1,4 @@
{
"components/Studio/LineHandles.tsx": 1,
"components/Studio/StudioSelectionChrome.tsx": 2,
"mobile/components/trek3d/MascotClip.tsx": 1,
"pages/tripPlanner/PlanMapStage.tsx": 4
"pages/tripPlanner/PlanMapStage.tsx": 4,
"mobile/components/trek3d/MascotClip.tsx": 1
}
+5 -5
View File
@@ -8,23 +8,23 @@
"src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2953,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157,
"src/components/Planner/PlaceInspector.tsx": 1290,
"src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/TripPlannerPage.tsx": 1131,
"src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 2057
"src/styles/studio.css": 2087
}
+3 -1
View File
@@ -51,7 +51,7 @@
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
"src/components/Planner/PlaceFormModal.test.tsx": 3,
"src/components/Planner/PlaceFormModal.tsx": 1,
"src/components/Planner/PlaceInspector.tsx": 16,
"src/components/Planner/PlaceInspector.tsx": 17,
"src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1,
"src/components/Planner/PlacesSidebar.tsx": 1,
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
@@ -82,6 +82,7 @@
"src/components/Settings/NotificationsTab.test.tsx": 1,
"src/components/Settings/PasskeysSection.tsx": 1,
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
"src/components/Studio/StudioCanvas.tsx": 1,
"src/components/Studio/StudioInspector.tsx": 1,
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
@@ -97,6 +98,7 @@
"src/components/shared/CustomSelect.test.tsx": 1,
"src/components/shared/PlaceAvatar.test.tsx": 2,
"src/components/shared/PlaceAvatar.tsx": 1,
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
"src/hooks/useDawarichSuggestions.test.ts": 1,
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
+4 -4
View File
@@ -100,10 +100,9 @@
"components/Roadtrip/serviceMarker.ts": 1,
"components/Settings/useAppearanceEditor.ts": 5,
"components/Studio/BookSheetsView.tsx": 2,
"components/Studio/PhotoView.tsx": 2,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/SpreadView.tsx": 3,
"components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioInspector.tsx": 2,
"components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 5,
@@ -133,6 +132,7 @@
"components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 3,
"components/shared/Skeleton.tsx": 2,
"components/shared/Spinner.tsx": 2,
@@ -252,7 +252,7 @@
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23,
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 27,
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
+1 -2
View File
@@ -40,8 +40,7 @@
"components/Settings/AppearanceSettingsTab.tsx": 1,
"components/Settings/PasskeysSection.tsx": 1,
"components/Studio/BookSheetsView.tsx": 1,
"components/Studio/PhotoView.tsx": 1,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/SpreadView.tsx": 2,
"components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 6,
-8
View File
@@ -18,7 +18,6 @@ import {
type AssistantChatRequest,
type AssistantDecisionsRequest,
type AssistantErrorCode,
type AssistantInsertFoundPlaceRequest,
type AssistantRenderUploadResponse,
type AssistantScope,
type AssistantShareRequest,
@@ -146,13 +145,6 @@ export const assistantApi = {
body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
// "+ Tour": a found place taken into the saved tour open in the planner, held as a change like "+ Day 3".
insertFoundPlace: (
tripId: number,
threadId: number,
body: AssistantInsertFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places/tour`, body).then((r) => r.data),
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
takeProposal: (
tripId: number,
@@ -1,5 +1,5 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { Check, MapPin, Plus, Route, Star } from 'lucide-react';
import { Check, MapPin, Plus, Star } from 'lucide-react';
import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n';
@@ -25,24 +25,6 @@ function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip:
);
}
/** "+ Tour": takes the place into the saved tour open in the planner as a stop, held as a change in the chat. */
function TourButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
const { t } = useTranslation();
if (!strip.tour) return null;
return (
<Tooltip label={t('assistant.place.addToTourHint', { name: strip.tour.name })} placement="top">
<button
type="button"
onClick={() => strip.addToTour(place)}
className={`${ACTION} ${tall ? 'h-10' : 'h-7'} min-w-0 px-2`}
>
<Route size={12} aria-hidden="true" className="flex-shrink-0" />
<span className="truncate">{t('assistant.place.addToTour')}</span>
</button>
</Tooltip>
);
}
type Category = ReturnType<Strip['categoryOf']>;
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
@@ -203,9 +185,8 @@ export default function AssistantPlaceCard({
)}
</span>
</button>
<div className="mt-auto flex min-w-0 gap-1.5 px-3 pb-3 pt-2.5">
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
<TourButton place={place} strip={strip} tall={tall} />
</div>
</li>
);
@@ -21,7 +21,6 @@ import { fetchPhoto } from '../../services/photoService';
import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { setOpenTour } from '../../store/openTourStore';
import { useTripStore } from '../../store/tripStore';
import { setForcedOffline } from '../../sync/networkMode';
import AssistantPlaceStrip from './AssistantPlaceStrip';
@@ -106,7 +105,6 @@ afterEach(() => {
useAuthStore.setState(AUTH, true);
globalThis.IntersectionObserver = RealObserver;
setForcedOffline(false);
setOpenTour(null);
vi.clearAllMocks();
});
@@ -240,22 +238,3 @@ describe('AssistantPlaceStrip', () => {
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
});
});
describe('"+ Tour" on a found place (P6)', () => {
it('FE-AI-PLACES-011: shows only while a saved tour of this trip is open, and holds the place as its stop', () => {
seed();
const insertFoundPlace = vi.fn(async () => 'held' as const);
useAssistantStore.setState({ insertFoundPlace });
setOpenTour({ tripId: TRIP, placeId: 64, name: 'Herzogstand' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: 'Add to tour' }));
expect(insertFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 64);
});
it('FE-AI-PLACES-012: a tour open on another trip, or a traveller who may not edit, gets no "+ Tour"', () => {
seed();
setOpenTour({ tripId: TRIP + 1, placeId: 64, name: 'Elsewhere' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.queryByRole('button', { name: 'Add to tour' })).toBeNull();
});
});
@@ -107,7 +107,6 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
no_tracks_found: { messageKey: 'assistant.error.noTracksFound', action: null },
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
generic: {
messageKey: 'assistant.error.generic',
@@ -127,10 +127,6 @@ function namedFirst(places: readonly AssistantFoundPlace[], text: string): Assis
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'propose_hike',
'create_hike',
'propose_tour',
'create_tour',
'create_paddle_tour',
'create_ski_tour',
'update_tour',
'propose_city_walk',
'create_city_walk',
@@ -138,9 +134,6 @@ const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'plan_ski_route',
'prepare_tour_change',
'save_tour_change',
'propose_round_hike',
'propose_hike_leg_way',
'import_wanderer_trail',
]);
/** The tool that keeps a note in the trip notes; kept at once, without a card. */
@@ -462,5 +455,4 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
difficulty: { labelKey: 'assistant.change.label.difficulty' },
track: { labelKey: 'assistant.change.label.track' },
packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' },
};
@@ -57,11 +57,6 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
font_mix: { labelKey: 'assistant.card.book.finding.font_mix' },
palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' },
repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' },
raster_effect: { labelKey: 'assistant.card.book.finding.raster_effect' },
hairline: { labelKey: 'assistant.card.book.finding.hairline' },
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
};
/** A page as the Studio names it: the cover, the first page, double page 3. */
@@ -37,7 +37,6 @@ export const BOOK_LOOK = {
// The server's own read: it waits for the Studio to draw the page, and says it looks at it.
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
list_book_assets: { stepKey: 'assistant.step.bookStickers' },
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
},
entities: {
@@ -1,4 +1,4 @@
import { BookmarkMinus, BookmarkPlus, Copy, Palette, Ruler, Sparkles } from 'lucide-react';
import { BookmarkMinus, BookmarkPlus, Copy, Ruler, Sparkles } from 'lucide-react';
import type { PackLook } from '../cardTypes';
@@ -19,7 +19,6 @@ export const BOOK_DESIGN_LOOK = {
verbs: {
designBook: { Icon: Sparkles, tone: 'info', labelKey: 'assistant.change.verb.designBook' },
setBookSetup: { Icon: Ruler, tone: 'info', labelKey: 'assistant.change.verb.setBookSetup' },
setBookTheme: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setBookTheme' },
duplicateBookPage: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.duplicateBookPage' },
saveBookLayout: { Icon: BookmarkPlus, tone: 'success', labelKey: 'assistant.change.verb.saveBookLayout' },
removeBookLayout: { Icon: BookmarkMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeBookLayout' },
@@ -24,19 +24,6 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
journal: { labelKey: 'assistant.card.bookDesign.style.journal' },
bold: { labelKey: 'assistant.card.bookDesign.style.bold' },
scrapbook: { labelKey: 'assistant.card.bookDesign.style.scrapbook' },
// A design in one of the themes is called by the theme's own name.
'poster-pop': { labelKey: 'studio.theme.name.posterPop' },
cutout: { labelKey: 'studio.theme.name.cutout' },
postcard: { labelKey: 'studio.theme.name.postcard' },
riviera: { labelKey: 'studio.theme.name.riviera' },
'fabric-classic': { labelKey: 'studio.theme.name.fabricClassic' },
'minimal-serif': { labelKey: 'studio.theme.name.minimalSerif' },
'grid-badge': { labelKey: 'studio.theme.name.gridBadge' },
'scrapbook-script': { labelKey: 'studio.theme.name.scrapbookScript' },
tropical: { labelKey: 'studio.theme.name.tropical' },
nordic: { labelKey: 'studio.theme.name.nordic' },
alpine: { labelKey: 'studio.theme.name.alpine' },
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
};
type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -39,8 +39,7 @@ describe('TripChatBlock', () => {
expect(screen.getByRole('region', { name: 'Continue in a trip chat' })).toBeInTheDocument();
expect(screen.getByText('Kyoto')).toBeInTheDocument();
expect(screen.getByText('Trip chat')).toBeInTheDocument();
// The dates, not the hidden label of the question, which has a "to" of its own.
expect(screen.getByText(/\d to \w/)).toBeInTheDocument();
expect(screen.getByText(/ to /)).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument();
expect(screen.getByText('12 places')).toBeInTheDocument();
expect(screen.getByText('What is still missing?')).toBeInTheDocument();
@@ -20,16 +20,7 @@ import { ROW_GLYPH, type RowChange } from './checklistModel';
* list, and the rows of a run of changes with their include choice.
*/
/** The band behind a group's head: its colour faint over the card's grey, the grey alone without one. */
function bandOf(color: string): string {
return color ? `color-mix(in srgb, ${color} 12%, var(--bg-secondary))` : 'var(--bg-secondary)';
}
/**
* A group's head as a section of the list: a full width band in its colour,
* the colour dot, its name, and at the end its badge (how many it holds) and
* who looks after it, the ones a change drops faded.
*/
/** A group's head: its colour, its name, a badge, and who looks after it (the ones a change drops faded). */
export function ChecklistHead({
color,
name,
@@ -47,17 +38,14 @@ export function ChecklistHead({
lead?: ReactNode;
}) {
return (
<div
className="mt-1.5 flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5"
style={{ background: bandOf(color) }}
>
<div className="flex min-w-0 items-center gap-2 pt-1">
{lead ?? <CategoryDot color={color} />}
<bdi title={name} className="min-w-0 flex-1 truncate text-caption font-semibold text-content">
<bdi title={name} className="min-w-0 truncate text-caption font-semibold text-content">
{name}
</bdi>
{(badge || people.length > 0 || removed.length > 0) && (
<span className="flex flex-none items-center gap-1.5">
{badge}
{badge}
{(people.length > 0 || removed.length > 0) && (
<span className="ms-auto flex flex-none items-center gap-1.5">
<PeopleStack people={people} />
{/* The people a change takes off, faded; the card says "No longer" in words under the head. */}
{removed.length > 0 && (
@@ -58,13 +58,13 @@ function BagSwatch({ bag, size = 'row' }: { bag: PackingCardBag | undefined; siz
/** A row's quantity and weight: quiet figures at its end, no frame each. */
const QUIET = 'text-caption tabular-nums text-content-muted';
/** How many rows a group holds, a white pill at the end of its band. */
/** How many rows a group holds, quiet beside its name. */
function GroupCount({ count }: { count: number }) {
const { t } = useTranslation();
return (
<span
aria-label={t('tours.packing.items', { count })}
className="inline-flex h-[18px] flex-none items-center rounded-full bg-surface-card px-1.5 text-caption font-semibold tabular-nums text-content-secondary shadow-sm"
className="flex-none text-caption tabular-nums text-content-faint"
>
{count}
</span>
@@ -231,7 +231,7 @@ function BagHead({
}) {
const { t } = useTranslation();
if (!bag) {
const badge = <GroupCount count={group.count} />;
const badge = inChange ? undefined : <GroupCount count={group.count} />;
const lead = <BagSwatch bag={undefined} size="head" />;
return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />;
}
@@ -298,7 +298,7 @@ export function PackingGroup({
<ChecklistHead
color={packingColorOf(key.slot, name)}
name={name}
badge={<GroupCount count={group.count} />}
badge={inChange ? undefined : <GroupCount count={group.count} />}
people={group.assignees}
removed={group.assigneesRemoved}
/>
@@ -5,7 +5,6 @@ import { useTranslation } from '../../../../i18n';
import { useSettingsStore } from '../../../../store/settingsStore';
import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters';
import { formatDistance, formatElevation } from '../../../../utils/units';
import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
/**
* How a card writes its numbers, the way the rest of TREK does for this
@@ -14,7 +13,7 @@ import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
* units. A card hands these to its words as `{value}`, never a raw number.
*/
export function useCardFormat() {
const { locale, t } = useTranslation();
const { locale } = useTranslation();
const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h');
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
return useMemo(() => {
@@ -38,8 +37,6 @@ export function useCardFormat() {
return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`;
},
count: (value: number) => number.format(value),
/** "5:30 min/km" from seconds per kilometre, per mile with distances in miles. */
pace: (secondsPerKm: number) => `${formatRunPace(secondsPerKm, unit)} ${runPaceUnitText(unit, t)}`,
};
}, [locale, twelveHour, unit, t]);
}, [locale, twelveHour, unit]);
}
@@ -1,5 +1,5 @@
/**
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042, FE-AI-TOUR-061 to FE-AI-TOUR-062
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042
*
* A route in the chat as the Tours tab shows a hike: its figures and grade,
* a profile only where the heights are known, the profile, the stop chips
@@ -387,44 +387,4 @@ describe('TourRouteCard', () => {
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1);
});
it('FE-AI-TOUR-061: a bike tour names its profile and its riding time, a run its pace, and waits for one it lacks', () => {
const bike: Route = {
...HIKE,
activity: 'bike',
routingProfile: 'gravel',
minutesBasis: 'router',
limit: null,
grades: null,
};
const { container } = renderCard(bike);
expect(screen.getByText('Gravel')).toBeInTheDocument();
expect(screen.queryByTestId('tour-grade')).toBeNull();
expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled();
expect(container.querySelector('[data-field="pace"]')).toBeNull();
const run: Route = {
...bike,
activity: 'run',
routingProfile: 'trail',
minutesBasis: 'pace',
paceSecondsPerKm: 330,
};
const paced = renderCard(run);
expect(screen.getByText('Trail running')).toBeInTheDocument();
expect(paced.container.querySelector('[data-field="pace"]')).toHaveTextContent('5:30 min/km');
renderCard({ ...run, paceSecondsPerKm: null, minutes: null });
expect(screen.getByText('Tell the assistant your pace to save this run.')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Plan this' });
expect(buttons[buttons.length - 1]).toBeDisabled();
});
it('FE-AI-TOUR-062: a whole paddle plan offers "Plan this"; one with a leg that failed does not', () => {
const plan: Route = { ...HIKE, activity: 'kayak', source: 'plan', limit: null, grades: null, schedule: null };
renderCard(plan);
expect(screen.getByRole('button', { name: 'Plan this' })).toBeInTheDocument();
renderCard({ ...plan, name: 'Broken', warnings: [{ kind: 'legs_failed', at: null, count: 1 }] });
expect(screen.getAllByRole('button', { name: 'Plan this' })).toHaveLength(1);
});
});
@@ -16,7 +16,7 @@ import RouteSketch from '../parts/RouteSketch';
import { useCardFormat } from '../parts/useCardFormat';
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel';
import { PROFILE_WORDS, routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
import { routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
/**
* The head of a route card: the route drawn small beside its name, the
@@ -63,16 +63,14 @@ export function TourRouteFigures({ card }: { card: TourRouteCard }) {
/**
* The grade of a hike, as the hike's own card shows it: the hardest SAC step
* along the way, uncertain when too little of it is graded, with what it
* means, the routing limit and where grades come from in its tooltip. A bike
* tour or a run names its profile instead, any other tour what kind it is.
* means, the routing limit and where grades come from in its tooltip. Any
* other tour says what kind it is instead.
*/
export function TourGrade({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
if (card.activity !== 'hike' || !card.grades) {
const icon = createElement(tourDayRowIcon(card.activity), { size: 10, strokeWidth: 2.2, 'aria-hidden': true });
// A bike tour or a run says what it was planned for: "Gravel", "Trail running".
const words = card.routingProfile ? PROFILE_WORDS[card.routingProfile] : TOUR_TYPE_WORDS[card.activity];
return <SoftPill icon={icon}>{t(words.labelKey)}</SoftPill>;
return <SoftPill icon={icon}>{t(TOUR_TYPE_WORDS[card.activity].labelKey)}</SoftPill>;
}
const { highest, unknownShare } = card.grades;
const uncertain = unknownShare >= 0.5;
@@ -140,7 +138,7 @@ export function TourRouteSketch({
export function TourRouteFields({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
const format = useCardFormat();
const fields: Array<{ key: string; label: string; value: string }> = factKeysOf(card).flatMap((key) => {
const fields = factKeysOf(card).flatMap((key) => {
const value = factValue(card, key);
if (value === null) return [];
// A field's label is short; how the time was reckoned is the figure badges' tooltip.
@@ -150,10 +148,6 @@ export function TourRouteFields({ card }: { card: TourRouteCard }) {
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
return [{ key, label, value: text }];
});
// A run's time is its distance at the runner's pace: the pace stands beside it.
if (card.paceSecondsPerKm != null) {
fields.push({ key: 'pace', label: t('tours.run.pace'), value: format.pace(card.paceSecondsPerKm) });
}
return <CardFields fields={fields} />;
}
@@ -179,9 +173,9 @@ export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; o
title={name}
meta={card.label ?? undefined}
>
{((card.activity === 'hike' && card.grades) || card.routingProfile || card.schedule) && (
{((card.activity === 'hike' && card.grades) || card.schedule) && (
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
{((card.activity === 'hike' && card.grades) || card.routingProfile) && <TourGrade card={card} />}
{card.activity === 'hike' && card.grades && <TourGrade card={card} />}
<DayPill card={card} />
</div>
)}
@@ -1,5 +1,5 @@
/**
* FE-AI-TOUR-033 to FE-AI-TOUR-034, FE-AI-TOUR-063
* FE-AI-TOUR-033 to FE-AI-TOUR-034
*
* A held tour change in its row: the route drawn small with its figures and
* grade and a small profile, the kind of tour as the row's tile; a tour whose
@@ -71,10 +71,4 @@ describe('TourRouteInRow', () => {
expect(delta).toHaveTextContent('+1.6 km');
expect(delta).toHaveTextContent('+20 min');
});
it('FE-AI-TOUR-063: a held bike tour or run names its profile in its row, never a hike grade', () => {
renderRow({ ...PENDING, activity: 'bike', routingProfile: 'road', grades: null, limit: null });
expect(screen.getByText('Road')).toBeInTheDocument();
expect(screen.queryByTestId('tour-grade')).toBeNull();
});
});
@@ -53,7 +53,7 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
<span className="min-w-0 flex-1">
<WayFigures way={legVariantOf(card, 'current')} unit={unit} />
</span>
{((grades && grades.highest !== null) || card.routingProfile) && <TourGrade card={card} />}
{grades && grades.highest !== null && <TourGrade card={card} />}
</span>
{previous && (
<VariantDelta
@@ -18,7 +18,6 @@ import CardHeader from '../parts/CardHeader';
import { CardSection } from '../parts/CardSection';
import WayFigures from './TourFigureLine';
import { TourRouteBody } from './TourRouteCard';
import { TourGrade } from './TourRouteHead';
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
@@ -115,8 +114,6 @@ function WayOption({
{grades && grades.highest !== null && (
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
)}
{/* Ways of a ride or a run may differ by profile: each says its own. */}
{entry.card.routingProfile && <TourGrade card={entry.card} />}
</span>
<WayFigures way={way} unit={unit} />
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
@@ -1,23 +1,5 @@
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
import {
ArrowLeftRight,
ChartNoAxesColumn,
Circle,
CircleCheck,
CircleSlash,
Copy,
Footprints,
History,
Luggage,
MapPinMinus,
MapPinPlus,
Palette,
Repeat,
Route,
Snowflake,
TriangleAlert,
Waves,
} from 'lucide-react';
import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
import type { EntityTarget, PackLook } from '../cardTypes';
@@ -113,21 +95,10 @@ export const TOUR_LOOK = {
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
reverseTour: { Icon: ArrowLeftRight, tone: 'info', labelKey: 'assistant.change.verb.reverseTour' },
closeLoop: { Icon: Repeat, tone: 'info', labelKey: 'assistant.change.verb.closeLoop' },
copyTour: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.copyTour' },
setRouteColor: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setRouteColor' },
classifySki: { Icon: Snowflake, tone: 'info', labelKey: 'assistant.change.verb.classifySki' },
setPaddlePlan: { Icon: Waves, tone: 'info', labelKey: 'assistant.change.verb.setPaddlePlan' },
decideObstacles: { Icon: TriangleAlert, tone: 'info', labelKey: 'assistant.change.verb.decideObstacles' },
restoreTrack: { Icon: History, tone: 'info', labelKey: 'assistant.change.verb.restoreTrack' },
addTourStop: { Icon: MapPinPlus, tone: 'success', labelKey: 'assistant.change.verb.addTourStop' },
removeTourStop: { Icon: MapPinMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeTourStop' },
},
tools: {},
steps: {
propose_city_walk: { stepKey: 'assistant.step.planningWalk' },
propose_tour: { stepKey: 'assistant.step.planningRoute' },
plan_paddle_route: { stepKey: 'assistant.step.planningRoute' },
plan_ski_route: { stepKey: 'assistant.step.planningRoute' },
get_tour_plan: { stepKey: 'assistant.step.readingTour' },
@@ -135,14 +106,6 @@ export const TOUR_LOOK = {
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
get_tour_trail_details: { stepKey: 'assistant.step.readingTour' },
propose_round_hike: { stepKey: 'assistant.step.findingRounds' },
propose_hike_leg_way: { stepKey: 'assistant.step.findingWays' },
search_wanderer_trails: { stepKey: 'assistant.step.searchingWanderer' },
preview_wanderer_trail: { stepKey: 'assistant.step.readingTrail' },
check_tour_source: { stepKey: 'assistant.step.checkingSource' },
// P7: a route file attached in the chat, read by the server into its tracks.
read_tour_file: { stepKey: 'assistant.step.readingTourFile' },
},
entities: {
tour: {
@@ -29,7 +29,6 @@ import {
sacAt,
sacPaint,
steepStretches,
takeWaitsKey,
wayGroups,
} from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel';
@@ -137,33 +136,13 @@ describe('tourRouteModel', () => {
});
});
it('FE-AI-TOUR-009: "Plan this" takes a planned way of every type, a city walk, a whole paddle or ski plan', () => {
it('FE-AI-TOUR-009: "Plan this" takes a planned hike or a city walk, nothing else', () => {
expect(canTake(HIKE)).toBe(true);
expect(canTake({ ...HIKE, routed: false })).toBe(false);
expect(canTake({ ...HIKE, activity: 'walk', routed: false })).toBe(true);
expect(canTake({ ...HIKE, activity: 'bike', routingProfile: 'gravel' })).toBe(true);
expect(canTake({ ...HIKE, activity: 'run', routingProfile: 'trail', routed: false })).toBe(false);
expect(canTake(SAVED)).toBe(false);
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(true);
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan' })).toBe(true);
// A plan with a leg that could not be planned is no tour yet; a hike is never a plan.
const failed = [{ kind: 'legs_failed' as const, at: null, count: 1 }];
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan', warnings: failed })).toBe(false);
expect(canTake({ ...HIKE, source: 'plan' })).toBe(false);
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(false);
expect(canTake({ ...HIKE, source: 'pending' })).toBe(false);
// Another way for a leg of a saved hike changes that hike; it is not created anew.
expect(canTake({ ...HIKE, placeId: 12 })).toBe(false);
});
it("FE-AI-TOUR-060: a run waits for the runner's pace; a bike tour, a hike and a run with a pace wait for nothing", () => {
expect(takeWaitsKey({ ...HIKE, activity: 'run', routingProfile: 'running' })).toBe('assistant.card.tour.needsPace');
expect(takeWaitsKey({ ...HIKE, activity: 'run', paceSecondsPerKm: 330 })).toBeNull();
expect(takeWaitsKey({ ...HIKE, activity: 'bike' })).toBeNull();
expect(takeWaitsKey(HIKE)).toBeNull();
// A ride's router time is its riding time; a run's time is at its pace.
expect(timeLabelKey({ activity: 'bike', minutesBasis: 'router' })).toBe('tours.bike.timeEstimated');
expect(timeLabelKey({ activity: 'run', minutesBasis: 'pace' })).toBe('tours.run.timeEstimated');
expect(timeLabelKey({ activity: 'hike', minutesBasis: 'router' })).toBe('tours.scope.routed');
});
it('FE-AI-TOUR-010: proposals of one answer form ways of three at most; anything else stands alone', () => {
@@ -226,31 +226,14 @@ export function routePoints(card: TourRouteCard): [number, number][] {
}
/**
* Whether "Plan this" can take the proposal: a city walk, a hike, a bike
* tour or a run whose way was planned, or a paddle or ski plan whose every
* leg was planned. A saved tour, a held change and the other way of a
* saved hike's leg (named by its place, changed with update_tour rather
* than created anew) cannot.
* Whether "Plan this" can take the proposal: a hike whose way was planned,
* or a city walk. A paddle or ski plan, a saved tour and a held change cannot.
*/
export function canTake(card: TourRouteCard): boolean {
if (card.source === 'plan') {
const whole = card.warnings.every((warning) => warning.kind !== 'legs_failed');
return (card.activity === 'kayak' || card.activity === 'ski') && card.routed && whole;
}
if (card.source !== 'proposal' || card.placeId !== null) return false;
return card.activity === 'walk' || (LAND_PLANS.has(card.activity) && card.routed);
if (card.source !== 'proposal') return false;
return card.activity === 'walk' || (card.activity === 'hike' && card.routed);
}
/** The proposals on land "Plan this" takes once their way is planned. */
const LAND_PLANS: ReadonlySet<TourRouteCard['activity']> = new Set(['hike', 'bike', 'run']);
/** Why "Plan this" waits on a card it can take: a run is saved with the runner's pace only. Null when nothing is missing. */
export function takeWaitsKey(card: TourRouteCard): string | null {
return card.activity === 'run' && card.paceSecondsPerKm == null ? NEEDS_PACE.labelKey : null;
}
const NEEDS_PACE = { labelKey: 'assistant.card.tour.needsPace' } as const;
/** A share as words take it, "40 %". */
export function percentText(share: number): string {
return `${Math.round(share * 100)} %`;
@@ -92,25 +92,10 @@ const TIME_WORDS = {
planned: { labelKey: 'tours.scope.planned' },
stations: { labelKey: 'tours.scope.plannedWithStays' },
piste: { labelKey: 'tours.ski.time' },
pace: { labelKey: 'tours.run.timeEstimated' },
} as const satisfies Record<NonNullable<TourRouteCard['minutesBasis']>, { labelKey: string }>;
/** What each bike and run profile is called, as the Tours tab names it. */
export const PROFILE_WORDS = {
touring: { labelKey: 'tours.bike.profile.touring' },
road: { labelKey: 'tours.bike.profile.road' },
gravel: { labelKey: 'tours.bike.profile.gravel' },
mtb: { labelKey: 'tours.bike.profile.mtb' },
running: { labelKey: 'tours.run.profile.running' },
trail: { labelKey: 'tours.run.profile.trail' },
} as const satisfies Record<NonNullable<TourRouteCard['routingProfile']>, { labelKey: string }>;
/**
* The name of a route's time: by how it was reckoned (a bike tour's router
* time is its riding time), else a paddle's own, else a walking time.
*/
/** The name of a route's time: by how it was reckoned, else a paddle's own, else a walking time. */
export function timeLabelKey(card: Pick<TourRouteCard, 'minutesBasis' | 'activity'>): string {
if (card.minutesBasis === 'router' && card.activity === 'bike') return 'tours.bike.timeEstimated';
if (card.minutesBasis) return TIME_WORDS[card.minutesBasis].labelKey;
return card.activity === 'kayak' ? 'tours.paddle.timeEstimated' : 'tours.planner.walkingEstimated';
}
@@ -11,7 +11,7 @@ import { hasUndecided } from '../../assistantParts';
import { useAssistantView } from '../../assistantViewContext';
import { useChangeHover } from '../../useChangeHover';
import { tourTarget } from './tourLook';
import { canTake, routePoints, takeWaitsKey } from './tourRouteModel';
import { canTake, routePoints } from './tourRouteModel';
import { takeInThread, takeOfProposal } from './tourTakeModel';
/**
@@ -104,8 +104,7 @@ function stateAfter(outcome: HoldOutcome, error: string | null): TakeState {
}
/**
* "Plan this" on a proposed tour (a hike, a bike tour, a run, a city walk)
* or a paddle or ski plan: one tap adds the tour. The
* "Plan this" on a proposed hike or city walk: one tap adds the tour. The
* server holds the tour it creates as a change, without a model turn, and
* the tap decides it at once, since tapping "Plan this" already is the
* decision; the assistant then answers to what was added. Once the tour is
@@ -149,10 +148,9 @@ export function useTakeProposal(toolCallId: string, card: TourRouteCard) {
}, []);
const visible = canTake(card) && !!trip && can('place_edit', trip);
let reasonKey: string | null;
let reasonKey: string | null = null;
if (offline) reasonKey = 'assistant.error.offline';
else if (waiting && state !== 'taken') reasonKey = 'assistant.composer.decideFirst';
else reasonKey = takeWaitsKey(card);
const disabled = reasonKey !== null || isRunning || threadId === null || state === 'busy';
const take = useCallback(async () => {
@@ -1,4 +1,4 @@
import { ASSISTANT_TOUR_FILE_EXTENSIONS, LLM_PHOTO_EXTENSIONS } from '@trek/shared';
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
import { type DragEvent, useState } from 'react';
import { useTranslation } from '../../i18n';
@@ -6,7 +6,7 @@ import { assistantScopeOf } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useToast } from '../shared/Toast';
/** What the booking import reads and the route files taken in as tours (the server checks again), and how big one file may be. */
/** What the booking import reads (the server checks again), and how big one file may be. */
const ATTACH_EXTENSIONS: readonly string[] = [
'.pdf',
'.eml',
@@ -16,7 +16,6 @@ const ATTACH_EXTENSIONS: readonly string[] = [
'.htm',
'.txt',
...LLM_PHOTO_EXTENSIONS,
...ASSISTANT_TOUR_FILE_EXTENSIONS,
];
const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
@@ -30,7 +29,7 @@ function hasFiles(e: DragEvent): boolean {
}
/**
* Hands a booking file or a route file (GPX, KML, KMZ) to the chat, from the paperclip, a paste or a drop on
* Hands a booking file to the chat, from the paperclip, a paste or a drop on
* the window. One file at a time, refused here with a toast when it is not
* one the import reads or too big, so a wrong file never costs a round trip.
* The drop handlers go on the whole chat; `dragging` lights its drop zone.
@@ -3,7 +3,6 @@ import { useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useOpenTourStore } from '../../store/openTourStore';
import { useCanDo } from '../../store/permissionsStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
@@ -42,10 +41,6 @@ function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng:
* Adding opens the planner's own place form, filled in with the place and on
* the day in focus: the traveller sees everything about it and saves it as
* any other place, so nothing waits for the chat's open decisions.
*
* P6: while a saved hike or city walk is open in the planner, "+ Tour" takes
* a place into it as a stop: held as a change in the chat like any other,
* so the stop is only saved once the traveller applies it.
*/
export function useAssistantPlaceStrip() {
const { tripId, onRevealMap } = useAssistantView();
@@ -65,8 +60,6 @@ export function useAssistantPlaceStrip() {
);
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft);
const insertFoundPlace = useAssistantStore((s) => s.insertFoundPlace);
const openTour = useOpenTourStore((s) => (s.open?.tripId === tripId ? s.open : null));
const point = useCardPoint();
const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
@@ -92,14 +85,6 @@ export function useAssistantPlaceStrip() {
openPlaceDraft(trip.id, place, focusDay?.id ?? null);
};
/** The saved tour a place can be taken into as a stop, null while none is open or nothing can be added. */
const tour = canAdd ? openTour : null;
/** Holds the place as a stop of the open tour, to decide in the chat. */
const addToTour = (place: AssistantFoundPlace) => {
if (tour) void insertFoundPlace(tripId, place.ref, tour.placeId);
};
/** Shows the place on the map; on the phone the chat steps aside first. */
const show = point.show;
@@ -111,5 +96,5 @@ export function useAssistantPlaceStrip() {
[hoverPoint]
);
return { canAdd, categoryOf, distanceOf, open, show, hover, tour, addToTour };
return { canAdd, categoryOf, distanceOf, open, show, hover };
}
@@ -1,4 +1,4 @@
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-027
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-021
// Plyr needs a real media pipeline, so the player is stubbed here.
vi.mock('./VideoPlayer', () => ({
@@ -17,7 +17,6 @@ vi.mock('../../api/websocket', () => ({
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import PhotoLightbox from './PhotoLightbox';
const samplePhotos = [
@@ -248,82 +247,4 @@ describe('PhotoLightbox', () => {
expect(img.style.transform).toContain('scale(2)');
expect(onClose).not.toHaveBeenCalled();
});
it('FE-COMP-LIGHTBOX-022: is a labelled modal dialog', () => {
render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} label="Show photos" />);
const dialog = screen.getByRole('dialog', { name: 'Show photos' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
});
it('FE-COMP-LIGHTBOX-023: takes the focus when it opens and gives it back to the opener when it closes', () => {
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
try {
const { unmount } = render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} label="Gallery" />);
expect(screen.getByRole('dialog', { name: 'Gallery' })).toHaveFocus();
unmount();
expect(opener).toHaveFocus();
} finally {
opener.remove();
}
});
it('FE-COMP-LIGHTBOX-024: names its arrows', () => {
render(<PhotoLightbox photos={samplePhotos} startIndex={1} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Next photo' }));
expect(screen.getByText('3 / 3')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Previous photo' }));
expect(screen.getByText('2 / 3')).toBeInTheDocument();
});
describe('right to left', () => {
beforeEach(() => {
// The direction follows the language, as in the app, where the provider also sets <html dir>.
useSettingsStore.setState((state) => ({ settings: { ...state.settings, language: 'ar' } }));
});
afterEach(() => {
document.documentElement.dir = '';
});
it('FE-COMP-LIGHTBOX-025: the arrow keys follow the reading direction', () => {
render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} />);
fireEvent.keyDown(window, { key: 'ArrowLeft' });
expect(screen.getByText('2 / 3')).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-026: a swipe to the right turns to the next photo', () => {
const { container } = render(<PhotoLightbox photos={samplePhotos} startIndex={1} onClose={vi.fn()} />);
const surface = container.firstElementChild as HTMLElement;
fireEvent.touchStart(surface, { touches: [{ clientX: 100, clientY: 200 }] });
fireEvent.touchEnd(surface, { changedTouches: [{ clientX: 200, clientY: 210 }] });
expect(screen.getByText('3 / 3')).toBeInTheDocument();
fireEvent.touchStart(surface, { touches: [{ clientX: 200, clientY: 200 }] });
fireEvent.touchEnd(surface, { changedTouches: [{ clientX: 100, clientY: 210 }] });
expect(screen.getByText('2 / 3')).toBeInTheDocument();
});
});
it('FE-COMP-LIGHTBOX-027: a caption with a source page links to it in a new tab, one without stays text', () => {
render(
<PhotoLightbox
photos={[
{ id: 'a', src: '/a.jpg', caption: 'Alice, CC BY 4.0', captionHref: 'https://commons.wikimedia.org/wiki/File:A.jpg' },
{ id: 'b', src: '/b.jpg', caption: 'Google', captionHref: null },
]}
onClose={vi.fn()}
/>,
);
const link = screen.getByRole('link', { name: 'Alice, CC BY 4.0' });
expect(link).toHaveAttribute('href', 'https://commons.wikimedia.org/wiki/File:A.jpg');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('Google', { selector: 'p' })).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});
+17 -44
View File
@@ -3,14 +3,12 @@ import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from 'lucide-react'
import VideoPlayer from './VideoPlayerLazy'
import { usePhotoZoom } from './usePhotoZoom'
import { Tooltip } from '../shared/Tooltip'
import { isRtlLanguage, useTranslation } from '../../i18n'
import { useTranslation } from '../../i18n'
interface LightboxPhoto {
id: string
src: string
caption?: string | null
/** Where the caption's credit points to (a photo's source page), opened in a new tab. */
captionHref?: string | null
provider?: string
asset_id?: string | null
owner_id?: number | null
@@ -21,8 +19,6 @@ interface Props {
photos: LightboxPhoto[]
startIndex?: number
onClose: () => void
/** What the dialog is called for assistive tech. */
label?: string
}
/** The round glass buttons of the top bar. */
@@ -32,21 +28,10 @@ const ROUND_BTN: React.CSSProperties = {
color: '#fff', cursor: 'pointer',
}
export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }: Props) {
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
const [idx, setIdx] = useState(startIndex)
const touchStart = useRef<{ x: number; y: number } | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const { t, language } = useTranslation()
// Right to left, the next photo lies to the left: the arrow keys and swipes follow the reading direction.
// Taken from the language, not read off <html dir>, so it stays put across re-renders.
const rtl = isRtlLanguage(language)
// Focus moves into the dialog when it opens and back to what opened it when it closes.
useEffect(() => {
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
dialogRef.current?.focus()
return () => opener?.focus()
}, [])
const { t } = useTranslation()
const photo = photos[idx]
// Zoom starts over on every photo (#1484).
@@ -61,12 +46,12 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') (rtl ? next : prev)()
if (e.key === 'ArrowRight') (rtl ? prev : next)()
if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') next()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [prev, next, onClose, rtl])
}, [prev, next, onClose])
// A pinch, a double tap or a pan of an enlarged photo belongs to the zoom; only
// the rest is a swipe that turns the page or closes the lightbox.
@@ -94,7 +79,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
}
// horizontal swipe
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
if (dx < 0 !== rtl) next()
if (dx < 0) next()
else prev()
}
touchStart.current = null
@@ -104,13 +89,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
style={{
outline: 'none',
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column',
@@ -165,15 +144,15 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
{hasPrev && (
<button type="button" onClick={prev} aria-label={t('journey.lightbox.previous')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{
position: 'absolute', insetInlineStart: 16, zIndex: 5,
<button type="button" onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', left: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronLeft size={22} className="rtl:rotate-180" />
<ChevronLeft size={22} />
</button>
)}
@@ -200,33 +179,27 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
{/* Next button */}
{hasNext && (
<button type="button" onClick={next} aria-label={t('journey.lightbox.next')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{
position: 'absolute', insetInlineEnd: 16, zIndex: 5,
<button type="button" onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', right: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronRight size={22} className="rtl:rotate-180" />
<ChevronRight size={22} />
</button>
)}
{/* Caption — bottom center overlay */}
{photo.caption && (
<div style={{ position: 'absolute', bottom: 20, insetInline: 0, zIndex: 5, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
<p style={{
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5, maxWidth: '70%', textAlign: 'center',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10, pointerEvents: 'auto',
}}>
{photo.captionHref ? (
<a href={photo.captionHref} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }} className="hover:underline">
{photo.caption}
</a>
) : photo.caption}
</p>
padding: '6px 14px', borderRadius: 10,
}}>{photo.caption}</p>
</div>
)}
</div>
@@ -15,6 +15,8 @@ import {
calculateAlternatives,
avoidedClasses,
routeEngineFor,
sameRoad,
furthestFrom,
osrmLegSegment,
} from './RouteCalculator'
@@ -1202,6 +1204,37 @@ describe('a trip that avoids something', () => {
expect(plain.avoidance).toBeUndefined()
})
it('FE-COMP-ROUTECALCULATOR-064: a road that runs the whole of another and turns off to a point and back is not that road', () => {
// The shape of a leg bent by a via: every point of the plain road lies on it, so
// asked only one way round the two read as one.
const plainRoad = { coordinates: [[61, 10], [61, 11], [61, 12]] as [number, number][], distance: 100_000, duration: 3600 }
const spur = { coordinates: [[61, 10], [61, 11], [61.3, 11], [61, 11], [61, 12]] as [number, number][], distance: 166_000, duration: 5400 }
expect(sameRoad(plainRoad, spur)).toBe(false)
expect(sameRoad(spur, plainRoad)).toBe(false)
expect(sameRoad(plainRoad, { ...plainRoad, distance: 104_000, duration: 3900 })).toBe(true)
})
it('FE-COMP-ROUTECALCULATOR-065: one road drawn with a different spacing of points is still one road', () => {
// Two engines' lines of one long road share almost no vertex. Measured to the nearest
// sampled vertex, a point on the same road sat half a sample off it: kilometres here.
// The dense line is sampled every tenth vertex, 0.05 degrees apart; the sparse one sits
// exactly between those samples, 1.8 km from the nearest, on the very same road.
const dense = Array.from({ length: 2001 }, (_, i) => [50, i * 0.005] as [number, number])
const sparse: [number, number][] = [
[50, 0],
...Array.from({ length: 200 }, (_, i) => [50, 0.025 + i * 0.05] as [number, number]),
[50, 10],
]
const a = { coordinates: dense, distance: 700_000, duration: 25_000 }
const b = { coordinates: sparse, distance: 712_000, duration: 27_000 }
expect(sameRoad(a, b)).toBe(true)
const off = furthestFrom([[50.02, 5]], dense)
expect(off?.index).toBe(0)
expect(off?.km).toBeCloseTo(2.22, 1)
})
it('FE-COMP-ROUTECALCULATOR-066: the avoidance offer beside an OSRM leg says when it crosses by ferry', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 63], [11, 63], [12, 63]] }, distance: 100000, duration: 3600 }] })),
+120 -1
View File
@@ -5,7 +5,7 @@ import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
import { furthestFrom, sameRoad, type RouteUsageSurface } from '@trek/shared'
import type { RouteUsageSurface } from '@trek/shared'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
@@ -803,6 +803,41 @@ export type RoadLine = Pick<RouteAlternative, 'coordinates' | 'distance' | 'dura
*/
const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const
/**
* How far two lines may stray from each other and still be the same road, in kilometres.
*
* Parallel carriageways, a different slip road onto the same motorway and the noise
* between two engines' idea of where a road is all sit well under this. A way somebody
* would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to
* Copenhagen, which is a genuinely different road, peaks 2.3 km off the default.
*/
const SAME_ROAD_KM = 0.5
/**
* Two lines are the same road.
*
* Length and duration used to decide this, and stopped being enough when a second engine
* started answering. Valhalla prices roads differently: measured over twelve European
* legs its distances ran 1.8 % above OSRM's, so on a 470 km leg the same road comes back
* thirteen kilometres apart and a 200 m tolerance calls it a discovery. Two identical
* blue lines on the map is exactly what that looks like. Duration is the worse of the
* two to lean on: over the same twelve legs it ranged from 14.7 % under OSRM's to 13.2 %
* over, with the sign set by the region rather than by the road.
*
* The geometry does not care which engine drew it, so it decides whenever there is one.
* The numbers stay as the fallback for a line with no shape to compare.
*
* Measured both ways round. Asked only whether `b` stays near `a`, a road that runs the
* whole of `b` and then turns off to a point and back passed as `b`, because every point
* of `b` is on it; that is exactly the shape of a leg bent by a via, and the picker and
* the check behind a choice both have to tell it from the plain road.
*/
export function sameRoad(a: RoadLine, b: RoadLine): boolean {
const there = furthestFrom(b.coordinates, a.coordinates)
const back = furthestFrom(a.coordinates, b.coordinates)
if (there && back) return Math.max(there.km, back.km) < SAME_ROAD_KM
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
}
/**
* Hosts that have already answered "I do not do exclude", by base URL.
@@ -1068,3 +1103,87 @@ function furthestPointFrom(line: [number, number][], reference: [number, number]
const furthest = furthestFrom(line, reference)
return furthest ? { lat: furthest.lat, lng: furthest.lng } : null
}
/**
* The same point, plus how far off it actually is.
*
* The search itself stays in squared degrees with a cosine on the longitude: it only has
* to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
* real distance rather than a rank, and half a degree is a different number of metres in
* Andalusia than in Lapland.
*
* `index` is where on `line` the point sits, so points taken off one line can be put back
* in the order that line drives through them.
*/
export function furthestFrom(
line: [number, number][],
reference: [number, number][],
): { index: number; lat: number; lng: number; km: number } | null {
if (!line.length || !reference.length) return null
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
// full cross product of two thousand-point lines is not worth the milliseconds.
const step = Math.max(1, Math.floor(reference.length / 200))
let bestIndex = -1
let bestNeighbour: [number, number] | null = null
let bestDist = -1
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
const near = nearestOn(line[i], reference, step)
if (near.d > bestDist) { bestDist = near.d; bestIndex = i; bestNeighbour = near.at }
}
if (bestIndex < 0) return null
const [lat, lng] = line[bestIndex]
const km = bestNeighbour ? haversineKm({ lat, lng }, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
return { index: bestIndex, lat, lng, km }
}
/**
* Where `reference` passes closest to `point`, as a squared distance in the same scaled
* degrees and the spot on the line it was measured to.
*
* Coarse, then fine. The sampled vertices find the stretch, and the segments either side
* of the nearest one find the road itself. Measured to the sampled vertex alone, a point
* on the very same road sat up to half a sample apart from it, which on a thousand
* kilometre leg is kilometres: two engines' lines of one motorway then read as two roads.
*/
function nearestOn(
point: [number, number],
reference: [number, number][],
step: number,
): { d: number; at: [number, number] } {
const cos = Math.cos((point[0] * Math.PI) / 180)
const squared = (p: [number, number]): number => {
const dLat = point[0] - p[0]
const dLng = (point[1] - p[1]) * cos
return dLat * dLat + dLng * dLng
}
let nearestIndex = 0
let nearest = Infinity
for (let j = 0; j < reference.length; j += step) {
const d = squared(reference[j])
if (d < nearest) { nearest = d; nearestIndex = j }
}
let at = reference[nearestIndex]
const last = Math.min(reference.length - 1, nearestIndex + step)
for (let k = Math.max(0, nearestIndex - step); k < last; k++) {
const on = closestOnSegment(point, reference[k], reference[k + 1], cos)
const d = squared(on)
if (d < nearest) { nearest = d; at = on }
}
return { d: nearest, at }
}
/** The point of segment a-b closest to `p`, with longitude scaled by `cos` so the plane is fair. */
function closestOnSegment(
p: [number, number],
a: [number, number],
b: [number, number],
cos: number,
): [number, number] {
const dx = (b[1] - a[1]) * cos
const dy = b[0] - a[0]
const length = dx * dx + dy * dy
if (length === 0) return a
const t = Math.max(0, Math.min(1, (((p[1] - a[1]) * cos) * dx + (p[0] - a[0]) * dy) / length))
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
}
@@ -110,8 +110,8 @@ describe('PlaceDetailsColumn', () => {
})
})
it('FE-PDC-004: renders author and licence under the Commons picture in play', async () => {
renderColumn({ selectedImageUrl: COMMONS_PHOTO.url })
it('FE-PDC-004: renders author and licence under every Commons picture', async () => {
renderColumn()
const credit = await screen.findByText('Alice')
// Links to the file description page, where the full terms are.
@@ -122,11 +122,9 @@ describe('PlaceDetailsColumn', () => {
it('FE-PDC-005: names the source when a picture has no author, instead of inventing one', async () => {
placeEnrichment.mockResolvedValue({ photos: [GOOGLE_PHOTO], description: null, facts: [] })
renderColumn({ selectedImageUrl: GOOGLE_PHOTO.url })
renderColumn()
expect(await screen.findByText('Google')).toBeInTheDocument()
// The tile's name carries the credit too, after a colon.
expect(screen.getByRole('button', { name: 'places.details.pickImage: Google' })).toBeInTheDocument()
})
it('FE-PDC-006: picking a picture reports its proxy URL', async () => {
@@ -613,24 +611,17 @@ describe('PlaceDetailsColumn — picture layout', () => {
withPhotos(3)
const { onPickImage } = renderColumn()
// Nothing is picked and there is no own picture to remove, so 'No picture'
// stays away and the first photo is credited.
await screen.findByText('Author 0')
expect(screen.queryByText('places.image.none')).not.toBeInTheDocument()
const tiles = screen.getAllByRole('button', { name: /places.details.pickImage/ })
fireEvent.mouseEnter(tiles[2])
expect(screen.getByText('Author 2')).toBeInTheDocument()
expect(screen.queryByText('Author 0')).not.toBeInTheDocument()
expect(onPickImage).not.toHaveBeenCalled()
fireEvent.mouseLeave(tiles[2])
expect(screen.queryByText('Author 2')).not.toBeInTheDocument()
expect(screen.getByText('Author 0')).toBeInTheDocument()
})
it('FE-PDC-037: credits the picture in play, licence included', async () => {
withPhotos(2)
renderColumn({ selectedImageUrl: '/api/maps/place-photo/p0/bytes' })
renderColumn()
expect(await screen.findByText('Author 0')).toBeInTheDocument()
expect(screen.getByText('Author 0').closest('a')).toHaveAttribute(
@@ -681,12 +672,9 @@ describe('PlaceDetailsColumn — dialog variant', () => {
const tile = await screen.findByRole('button', { name: /places.details.pickImage/ })
expect(tile).not.toHaveAttribute('title')
// Hovering the tile moves the credit line to it.
fireEvent.mouseEnter(tile)
const licence = screen.getByText('CC BY-SA 4.0', { selector: 'a[href*="creativecommons"]' })
const line = licence.parentElement?.parentElement as HTMLElement
expect(line).toHaveTextContent('Alice')
expect(line.textContent).not.toContain('·')
expect(licence.closest('p')).toHaveTextContent('Alice')
expect(licence.closest('p')?.textContent).not.toContain('·')
fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(COMMONS_PHOTO.url)
@@ -800,41 +788,3 @@ describe('PlaceDetailsColumn — dialog variant', () => {
expect(await screen.findByText('places.details.error', undefined, { timeout: 4000 })).toBeInTheDocument()
})
})
/**
* FE-PDC-050..051 — the editor's own pictures beside the sources' ones.
*/
describe('PlaceDetailsColumn — the place\'s own pictures', () => {
const own = (over: Record<string, unknown> = {}) => ({
currentUrl: '/uploads/places/own.jpg',
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
...over,
})
it('FE-PDC-050: offers the upload and the current picture even while nothing is selected', () => {
const mine = own()
renderColumn({ selection: null, image: { own: mine, chosen: { kind: 'current' } } })
expect(screen.getByText('places.details.pickImage')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'places.uploadImage' })).toBeInTheDocument()
const current = screen.getByRole('button', { name: 'places.image.current' })
expect(current).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(current)
expect(mine.onRemove).toHaveBeenCalledTimes(1)
})
it('FE-PDC-051: the sources\' photos join the own ones once they arrive, the choice following the image prop', async () => {
const { onPickImage } = renderColumn({ image: { own: own(), chosen: { kind: 'url', url: COMMONS_PHOTO.url } } })
const tile = await screen.findByRole('button', { name: 'places.details.pickImage: Alice' })
expect(tile).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'places.image.current' })).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(null)
})
})
@@ -4,6 +4,7 @@ import {
ArrowRight,
Bike,
Building2,
Check,
ChefHat,
ChevronDown,
ChevronUp,
@@ -32,22 +33,22 @@ import { fs } from '../shared/DialogShell'
import { LABEL } from '../shared/dialogParts'
import { COLUMN_CARD, ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from './placeDialogParts'
import type { TranslationFn } from '../../types'
import PlaceImagePicker, { type PlaceImageOwn } from './PlaceImagePicker'
import { chosenImage, offersPictures, type ChosenImage } from './placeImageChoice'
import { cacheKeyFor, readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment'
export { __clearEnrichmentCacheForTests, type PlaceDetailsSelection } from './placeEnrichment'
/** The place the column is describing. Null while nothing is selected. */
export interface PlaceDetailsSelection {
placeId?: string
lat: number
lng: number
name: string
/** The picked search result, so the server can skip its own details lookup. */
details?: Record<string, unknown>
}
interface PlaceDetailsColumnProps {
selection: PlaceDetailsSelection | null
/** Currently chosen hero image, so the picked tile can show as picked. */
selectedImageUrl?: string | null
selectedImageUrl?: string
onPickImage: (url: string | null) => void
/**
* The editor's own pictures (upload, the current one, attached images) and
* which tile is chosen; without it only the sources' pictures are offered.
*/
image?: { own: PlaceImageOwn; chosen: ChosenImage }
onAdoptDescription: (text: string) => void
/** The looked-up opening hours, once they arrive, so the form can take them over (#2472). */
onHours?: (hours: PlaceHours) => void
@@ -74,6 +75,47 @@ interface PlaceDetailsColumnProps {
t: TranslationFn
}
/**
* Module-level cache plus sessionStorage, same shape as usePlaceDetails in
* PlaceInspector. Clicking back and forth between two search results must not
* pay for the provider fan-out twice.
*/
const enrichmentCache = new Map<string, MapsPlaceEnrichmentResult>()
/** Test seam: the module-level cache otherwise leaks between cases. */
export function __clearEnrichmentCacheForTests(): void {
enrichmentCache.clear()
}
function readSession(key: string): MapsPlaceEnrichmentResult | undefined {
try {
const raw = sessionStorage.getItem(key)
return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined
} catch {
return undefined
}
}
function writeSession(key: string, value: MapsPlaceEnrichmentResult): void {
try {
sessionStorage.setItem(key, JSON.stringify(value))
} catch {
/* private mode / quota — the in-memory cache still helps for this session */
}
}
/**
* Bumped with the server's CACHE_VERSION. sessionStorage outlives a deploy, so
* without it the tab that was open while the fix shipped keeps replaying the
* answer the fix was about — and reports it as still broken.
*/
const ENRICH_CACHE_V = 4
function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string {
const id = selection.placeId || `coords:${selection.lat}:${selection.lng}`
return `enrich_v${ENRICH_CACHE_V}_${id}_${language}`
}
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
/**
@@ -119,7 +161,6 @@ export default function PlaceDetailsColumn({
locale = 'en-US',
fluid = false,
variant = 'sheet',
image,
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const dialog = variant === 'dialog'
@@ -142,8 +183,9 @@ export default function PlaceDetailsColumn({
return
}
const cached = readCachedEnrichment(selection, language)
const cached = enrichmentCache.get(selectionKey) ?? readSession(selectionKey)
if (cached) {
enrichmentCache.set(selectionKey, cached)
setData(cached)
setState('ready')
return
@@ -175,7 +217,8 @@ export default function PlaceDetailsColumn({
)
.then((result) => {
if (controller.signal.aborted) return
storeEnrichment(selection, language, result)
enrichmentCache.set(selectionKey, result)
writeSession(selectionKey, result)
setData(result)
setState('ready')
})
@@ -236,16 +279,6 @@ export default function PlaceDetailsColumn({
)}
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
{/* The place's picture first: its own ones are there whatever the sources answer. */}
<PictureBlock
photos={state === 'ready' && !data?.disabled ? (data?.photos ?? []) : []}
chosen={image?.chosen ?? chosenImage(selectedImageUrl, null, null)}
onPickImage={onPickImage}
own={image?.own}
dialog={dialog}
t={t}
/>
{/* Nothing picked yet, which is a state rather than a fault: the mascot
waits with an idle look instead of a sad one. The dialog says the
same in a quiet card, and the column keeps its width either way. */}
@@ -298,6 +331,7 @@ export default function PlaceDetailsColumn({
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
<>
<PhotoStrip photos={data?.photos ?? []} selectedImageUrl={selectedImageUrl} onPickImage={onPickImage} dialog={dialog} t={t} />
<RatingRow rating={data?.rating ?? null} locale={locale} dialog={dialog} />
<OpeningHoursBlock
hours={data?.hours ?? null}
@@ -355,31 +389,119 @@ export default function PlaceDetailsColumn({
)
}
/** The picker under its label; nothing while there is no picture to offer. */
function PictureBlock({
/** The name to put under a picture. Google gives no author, so it gets its own name. */
function creditOf(photo: PlacePhotoCandidate): string {
return photo.attribution || sourceLabelFor(photo.source)
}
/**
* The picture grid.
*
* A grid rather than one large image: this sits beside a form in a dialog, and
* a picture that takes half the column pushes the facts and the description out
* of sight. Three columns at 320px gives ~96px tiles, which is enough to tell
* the pictures apart while leaving the rest of the column visible.
*
* Only the picture in play is credited in full — crediting all of them at once
* cost two lines each and drowned the column, and the licence obligation
* attaches to the one that gets used. Every tile still carries the full credit
* as its tooltip and links to its source page.
*/
function PhotoStrip({
photos,
chosen,
selectedImageUrl,
onPickImage,
own,
dialog,
dialog = false,
t,
}: {
photos: PlacePhotoCandidate[]
chosen: ChosenImage
selectedImageUrl?: string
onPickImage: (url: string | null) => void
own?: PlaceImageOwn
dialog: boolean
dialog?: boolean
t: TranslationFn
}): React.ReactElement | null {
if (!offersPictures(photos, own)) return null
const picker = <PlaceImagePicker photos={photos} chosen={chosen} onPickUrl={onPickImage} own={own} dialog={dialog} t={t} />
const [hovered, setHovered] = useState<string | null>(null)
if (photos.length === 0) return null
const shown = photos.find((p) => p.url === (hovered ?? selectedImageUrl)) ?? photos[0]
const body = (
<>
<div className="grid grid-cols-3 gap-1.5">
{photos.map((photo) => (
<PhotoTile
key={photo.key}
photo={photo}
selected={selectedImageUrl === photo.url}
onPick={onPickImage}
onHover={setHovered}
dialog={dialog}
t={t}
/>
))}
</div>
<p className={dialog ? 'mt-2 flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint' : 'text-caption leading-tight text-content-faint truncate'}>
<PhotoCredit photo={shown} dialog={dialog} />
</p>
</>
)
return (
<Block label={t('places.details.pickImage')} dialog={dialog}>
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{picker}</div> : picker}
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{body}</div> : body}
</Block>
)
}
function PhotoTile({
photo,
selected,
onPick,
onHover,
dialog = false,
t,
}: {
photo: PlacePhotoCandidate
selected: boolean
onPick: (url: string | null) => void
onHover: (url: string | null) => void
/** On a white card, and without the native tooltip: hovering a tile already moves the credit line under the grid to it. */
dialog?: boolean
t: TranslationFn
}): React.ReactElement {
const credit = creditOf(photo)
return (
<button
type="button"
onClick={() => onPick(selected ? null : photo.url)}
onMouseEnter={() => onHover(photo.url)}
onMouseLeave={() => onHover(null)}
onFocus={() => onHover(photo.url)}
onBlur={() => onHover(null)}
aria-pressed={selected}
aria-label={`${t('places.details.pickImage')} — ${credit}`}
title={dialog ? undefined : `${credit}${photo.license ? ` · ${photo.license}` : ''}`}
className={`group relative block w-full aspect-square overflow-hidden rounded-lg transition-shadow ${
selected
? `ring-2 ring-accent ring-offset-2 ${dialog ? 'ring-offset-surface-card' : 'ring-offset-surface-secondary'}`
: 'ring-1 ring-edge hover:ring-content-muted'
}`}
>
<img
src={photo.url}
alt=""
loading="lazy"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
/>
{selected && (
<span className="absolute top-1 end-1 rounded-full bg-accent p-0.5 shadow-card">
<Check className="w-2.5 h-2.5 text-accent-on" />
</span>
)}
</button>
)
}
/**
* Who the description came from, as the reader would name them.
*
@@ -411,6 +533,75 @@ function descriptionSourceLabel(
}
}
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
if (source === 'google') return 'Google'
if (source === 'wikipedia') return 'Wikipedia'
return 'Wikimedia Commons'
}
/**
* Author and licence for the picture currently in play.
*
* Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one
* without naming its author does not satisfy those terms. When a source hands
* us no author (Google), we say where it came from rather than inventing one.
* Rendered inline — the container supplies the colour and the truncation.
*/
function PhotoCredit({ photo, dialog = false }: { photo: PlacePhotoCandidate; dialog?: boolean }): React.ReactElement {
const credit = creditOf(photo)
if (dialog) {
// Two pieces side by side with room between them, rather than joined by a dot.
return (
<>
<span className="min-w-0 truncate">
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
</span>
{photo.license && (
<span className="flex-none">
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</span>
)}
</>
)
}
return (
<>
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
{photo.license && (
<>
{' · '}
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</>
)}
</>
)
}
/**
* Shown when the free sources found nothing and no Google key is configured.
@@ -1,65 +0,0 @@
// FE-PLANNER-PLFORMMEDIA-001 to FE-PLANNER-PLFORMMEDIA-003: the files waiting for a new
// place, and the place's picture in the form where no details column stands beside it.
import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { PendingFiles, PlaceImageSection } from './PlaceFormMedia';
import type { PlaceImageOwn } from './PlaceImagePicker';
vi.mock('../../api/authUrl', () => ({ fetchImageAsBlob: vi.fn(async () => 'blob:x') }));
const t = ((key: string) => key) as never;
describe('PendingFiles', () => {
it('FE-PLANNER-PLFORMMEDIA-001: the attach button opens the input, which adds the files', () => {
const fileRef = createRef<HTMLInputElement>();
const onAdd = vi.fn();
render(<PendingFiles files={[]} fileRef={fileRef} onAdd={onAdd} onRemove={vi.fn()} t={t} />);
expect(screen.queryByTestId('pending-files')).not.toBeInTheDocument();
const openPicker = vi.spyOn(fileRef.current as HTMLInputElement, 'click');
fireEvent.click(screen.getByRole('button', { name: 'files.attach' }));
expect(openPicker).toHaveBeenCalledTimes(1);
fireEvent.change(fileRef.current as HTMLInputElement, { target: { files: [new File(['x'], 'a.pdf')] } });
expect(onAdd).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLFORMMEDIA-002: each waiting file is listed by name and removed by its index', () => {
const onRemove = vi.fn();
render(
<PendingFiles
files={[new File(['x'], 'ticket.pdf'), new File(['y'], 'map.png')]}
fileRef={createRef<HTMLInputElement>()}
onAdd={vi.fn()}
onRemove={onRemove}
t={t}
/>
);
expect(screen.getByTestId('pending-files')).toHaveTextContent('ticket.pdf');
expect(screen.getByText('map.png')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[1]);
expect(onRemove).toHaveBeenCalledWith(1);
});
});
describe('PlaceImageSection', () => {
it("FE-PLANNER-PLFORMMEDIA-003: the picker sits under the field's label with the place's own pictures", () => {
const own: PlaceImageOwn = {
currentUrl: '/uploads/places/c.jpg',
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
};
const onPickUrl = vi.fn();
render(<PlaceImageSection image={{ own, chosen: { kind: 'none' } }} onPickUrl={onPickUrl} t={t} />);
expect(screen.getByText('places.details.pickImage')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'places.uploadImage' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'places.image.current' }));
expect(onPickUrl).toHaveBeenCalledWith('/uploads/places/c.jpg');
});
});
@@ -1,84 +0,0 @@
import { Paperclip, X } from 'lucide-react';
import React from 'react';
import type { TranslationFn } from '../../types';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { LABEL } from '../shared/dialogParts';
import PlaceImagePicker, { type PlaceImageOwn } from './PlaceImagePicker';
import { COLUMN_CARD, WHITE_BUTTON } from './placeDialogParts';
import { offersPictures, type ChosenImage } from './placeImageChoice';
/** Files chosen before the place exists: they are uploaded once the save has given it an id. */
export function PendingFiles({
files,
fileRef,
onAdd,
onRemove,
t,
}: {
files: File[];
fileRef: React.RefObject<HTMLInputElement | null>;
onAdd: (e: React.ChangeEvent<HTMLInputElement>) => void;
onRemove: (index: number) => void;
t: TranslationFn;
}) {
return (
<div className="min-w-0">
<div className={LABEL}>{t('files.title')}</div>
<input ref={fileRef} type="file" multiple className="hidden" onChange={onAdd} />
<button type="button" onClick={() => fileRef.current?.click()} className={WHITE_BUTTON} style={fs(13, 'body')}>
<Paperclip size={14} aria-hidden="true" />
{t('files.attach')}
</button>
{files.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1" data-testid="pending-files">
{files.map((file, idx) => (
<div
key={idx}
className="flex items-center gap-2 rounded-[8px] border border-edge-faint bg-surface-card px-2.5 py-1.5"
style={fs(12, 'body')}
>
<Paperclip size={11} className="flex-none text-content-faint" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-content-secondary">{file.name}</span>
<Tooltip label={t('common.delete')}>
<button
type="button"
onClick={() => onRemove(idx)}
aria-label={t('common.delete')}
className="flex flex-none text-content-faint transition-colors hover:text-danger"
>
<X size={12} />
</button>
</Tooltip>
</div>
))}
</div>
)}
</div>
);
}
/**
* The place's picture in the form itself, where no details column stands
* beside it (place details switched off): the same picker, with the place's
* own pictures and an upload, under the field's label.
*/
export function PlaceImageSection({
image,
onPickUrl,
t,
}: {
image: { own: PlaceImageOwn; chosen: ChosenImage };
onPickUrl: (url: string | null) => void;
t: TranslationFn;
}) {
if (!offersPictures([], image.own)) return null;
return (
<div className="min-w-0">
<div className={LABEL}>{t('places.details.pickImage')}</div>
<div className={`${COLUMN_CARD} p-2`}>
<PlaceImagePicker photos={[]} chosen={image.chosen} onPickUrl={onPickUrl} own={image.own} dialog t={t} />
</div>
</div>
);
}
@@ -1,8 +1,8 @@
// FE-PLANNER-PLFORMH-001 to FE-PLANNER-PLFORMH-011: the place form's openings and save
// FE-PLANNER-PLFORMH-001 to FE-PLANNER-PLFORMH-010: the place form's openings and save
// payload, as the desktop dialog and the phone sheet both build them.
import { describe, expect, it } from 'vitest';
import type { Assignment, Place, TripFile } from '../../types';
import type { Assignment, Place } from '../../types';
import {
DEFAULT_FORM,
duplicateName,
@@ -155,18 +155,4 @@ describe('placeFormPayload', () => {
const payload = placeFormPayload(form, [], null, (lat, lng) => ({ at: [lat, lng] }));
expect(payload.at).toEqual([48.86, null]);
});
it('FE-PLANNER-PLFORMH-011: a waiting picture travels as the file, or as the attached file id, or not at all', () => {
expect(placeFormPayload(form, [], null)._pendingImage).toBeUndefined();
const picture = new File(['x'], 'front.png');
expect(
placeFormPayload(form, [], null, undefined, { kind: 'upload', file: picture, preview: 'blob:x' })._pendingImage
).toBe(picture);
const attached = { id: 12, mime_type: 'image/png' } as TripFile;
expect(placeFormPayload(form, [], null, undefined, { kind: 'attached', file: attached })._pendingImage).toEqual({
fileId: 12,
});
// A dropped picture is the field's null, and it is saved as such.
expect(placeFormPayload({ ...form, image_url: null }, [], null).image_url).toBeNull();
});
});
@@ -1,5 +1,4 @@
import type { Assignment, Place, RoadtripStopType } from '@trek/shared'
import { pendingImagePayload, type PendingImagePayload, type PendingPlaceImage } from './placeImageChoice'
import type { PlaceDetailsSelection } from './PlaceDetailsColumn'
export interface PlaceFormData {
@@ -28,8 +27,7 @@ export interface PlaceFormData {
// Hero image picked from the detail column. Optional and absent from
// DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets
// it, and places.service already writes image_url through on create/update.
// Null drops the place's picture on save; undefined keeps it.
image_url?: string | null
image_url?: string
// Day-specific note on the in-context assignment (#2163). Only hydrated when
// the form opened with an assignment in context; both forms drop it from the
// submit payload when unchanged, and useTripPlanner strips it off the place
@@ -310,8 +308,6 @@ export interface PlaceFormPayload extends Omit<PlaceFormData, 'lat' | 'lng' | 'c
category_id: string | null
/** Files chosen before the place existed: uploaded once the save has given it an id. */
_pendingFiles?: File[]
/** A picture waiting to become the place's own: a file from the device, or an attached file's id. */
_pendingImage?: PendingImagePayload
}
/**
@@ -326,7 +322,6 @@ export function placeFormPayload<E extends object = object>(
pendingFiles: File[],
assignment: Pick<Assignment, 'notes'> | null | undefined,
extra?: (lat: number | null, lng: number | null) => E,
pendingImage: PendingPlaceImage | null = null,
): PlaceFormPayload & E {
const lat = form.lat ? Number.parseFloat(form.lat) : null
const lng = form.lng ? Number.parseFloat(form.lng) : null
@@ -337,7 +332,6 @@ export function placeFormPayload<E extends object = object>(
category_id: form.category_id || null,
...(extra ? extra(lat, lng) : {}),
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
_pendingImage: pendingImagePayload(pendingImage),
} as PlaceFormPayload & E
if (!assignment || (form.assignment_notes ?? '') === (assignment.notes ?? '')) {
delete payload.assignment_notes
@@ -1,89 +0,0 @@
// FE-PLANNER-PLACEFORM-125 to FE-PLANNER-PLACEFORM-127: the place's picture in the desktop
// dialog. Nothing is sent before the save; the waiting picture travels with it.
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace, buildTripFile } from '../../../tests/helpers/factories';
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import PlaceFormModal from './PlaceFormModal';
vi.mock('../shared/CustomTimePicker', async () => ({
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
}));
vi.mock('../../api/authUrl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/authUrl')>()),
fetchImageAsBlob: vi.fn(async () => 'blob:attached'),
}));
const imageInput = () => document.querySelector('input[type="file"][accept^="image"]') as HTMLInputElement;
beforeEach(() => {
resetAllStores();
seedPlaceFormStores();
// Without the details column the picker sits in the form itself.
seedStore(useAuthStore, { placesEnrichEnabled: false });
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:form-preview');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('PlaceFormModal, the picture', () => {
it('FE-PLANNER-PLACEFORM-125: a new place gets a picture from the device, uploaded once the save made it', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
render(<PlaceFormModal {...placeFormProps} onSave={onSave} />);
expect(screen.getByText('Pick a picture')).toBeInTheDocument();
const picture = new File(['x'], 'front.png', { type: 'image/png' });
fireEvent.change(imageInput(), { target: { files: [picture] } });
const tile = await screen.findByRole('button', { name: 'Upload image', pressed: true });
expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:form-preview');
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Louvre', _pendingImage: picture }));
});
it('FE-PLANNER-PLACEFORM-126: an edited place keeps its picture as chosen, or takes an attached image by its id', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const place = buildPlace({ id: 61, name: 'Gallery', image_url: '/uploads/places/61.jpg' });
useTripStore.setState({
files: [buildTripFile({ id: 88, place_id: 61, mime_type: 'image/jpeg', original_name: 'hall.jpg' })],
} as never);
render(<PlaceFormModal {...placeFormProps} place={place} onSave={onSave} />);
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: 'hall.jpg' }));
expect(screen.getByRole('button', { name: 'hall.jpg' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'false');
await user.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ _pendingImage: { fileId: 88 } }));
});
it('FE-PLANNER-PLACEFORM-127: removing the picture saves the place without one, and nothing waits', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const place = buildPlace({ id: 62, name: 'Bridge', image_url: '/uploads/places/62.jpg' });
render(<PlaceFormModal {...placeFormProps} place={place} onSave={onSave} />);
await user.click(screen.getByRole('button', { name: 'Remove image' }));
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText('No picture')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
const payload = onSave.mock.calls[0][0];
expect(payload.image_url).toBeNull();
expect(payload._pendingImage).toBeUndefined();
});
});
@@ -913,7 +913,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-031: pending files list shows file names after adding', async () => {
render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput).toBeTruthy();
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
@@ -925,7 +925,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-032: removing a pending file removes it from the list', async () => {
const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['x'], 'remove-me.jpg', { type: 'image/jpeg' });
fireEvent.change(fileInput, { target: { files: [file] } });
@@ -1470,7 +1470,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-057: the Attach button opens the hidden file input', async () => {
const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
await user.click(screen.getByRole('button', { name: /Attach/i }));
expect(clickSpy).toHaveBeenCalled();
@@ -2422,7 +2422,7 @@ describe('PlaceFormModal on the dialog frame', () => {
it('FE-PLANNER-PLACEFORM-103: a pending file is removed by its named button', async () => {
const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />);
fireEvent.change(document.querySelector('input[type="file"][multiple]') as HTMLInputElement, {
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [new File(['x'], 'ticket.pdf', { type: 'application/pdf' })] },
});
await user.click(within(screen.getByTestId('pending-files')).getByRole('button', { name: 'Delete' }));
@@ -8,11 +8,9 @@ import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn from './PlaceDetailsColumn'
import { PendingFiles, PlaceImageSection } from './PlaceFormMedia'
import { usePlaceImageField } from './usePlaceImageField'
import { useToast } from '../shared/Toast'
import { Tooltip } from '../shared/Tooltip'
import { Search, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react'
import { Search, Paperclip, X, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { PlaceContactFields } from './PlaceContactFields'
import { weekFromPeriods } from './placeHours'
@@ -43,6 +41,7 @@ import {
} from '../shared/DialogShell'
import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts'
import { SoftPill, tintOf } from './planParts'
import { WHITE_BUTTON } from './placeDialogParts'
import { TimeSection } from './PlaceFormTimeSection'
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
@@ -122,11 +121,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const {
form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving,
duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage,
adoptDescription, warnIfDuplicate, pendingImage, stageUpload, stageAttached, removeImage,
adoptDescription, warnIfDuplicate,
} = usePlaceForm({ t, toast, canUploadFiles })
const image = usePlaceImageField({
place, imageUrl: form.image_url, pendingImage, canUploadFiles, stageUpload, stageAttached, removeImage,
})
// The index the picked place came from, as its row named it, shown as a pill
// in the head band. Null for a place typed by hand or taken from the saved list.
const [pickedSource, setPickedSource] = useState<string | null>(null)
@@ -362,7 +358,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
...(serviceStop
? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement }
: {}),
}), pendingImage)
}))
const saved = await onSave(payload)
// Open the Costs editor for the saved place when the user asked to
// create/edit its linked expense — gated on an id, so a create that the
@@ -446,7 +442,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
handleRemoveFile: removeFile,
handlePaste,
pickImage,
image,
adoptDescription,
hasTimeError,
handleSubmit,
@@ -611,7 +606,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
selection={detailsSelection}
selectedImageUrl={form.image_url}
onPickImage={S.pickImage}
image={S.image}
onAdoptDescription={S.adoptDescription}
onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))}
hasDescription={!!form.description.trim()}
@@ -885,7 +879,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
maxLength={2000}
/>
{!showDetails && <PlaceImageSection image={S.image} onPickUrl={S.pickImage} t={t} />}
{/* The link and the files side by side, as on a booking. */}
<div className={canUploadFiles ? GRID_2 : ''}>
<EditorField label={t('places.formWebsite')} htmlFor={`${fieldId}-website`}>
@@ -1124,3 +1117,44 @@ function MarkdownField({ id, label, value, onChange, placeholder, maxLength }: {
</DialogSection>
)
}
/** Files chosen before the place exists: they are uploaded once the save has given it an id. */
function PendingFiles({ files, fileRef, onAdd, onRemove, t }: {
files: File[]
fileRef: React.RefObject<HTMLInputElement | null>
onAdd: (e: React.ChangeEvent<HTMLInputElement>) => void
onRemove: (index: number) => void
t: Translate
}) {
return (
<div className="min-w-0">
<div className={LABEL}>{t('files.title')}</div>
<input ref={fileRef} type="file" multiple className="hidden" onChange={onAdd} />
<button
type="button"
onClick={() => fileRef.current?.click()}
className={WHITE_BUTTON}
style={fs(13, 'body')}
>
<Paperclip size={14} aria-hidden="true" />
{t('files.attach')}
</button>
{files.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1" data-testid="pending-files">
{files.map((file, idx) => (
<div key={idx} className="flex items-center gap-2 rounded-[8px] border border-edge-faint bg-surface-card px-2.5 py-1.5" style={fs(12, 'body')}>
<Paperclip size={11} className="flex-none text-content-faint" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-content-secondary">{file.name}</span>
<Tooltip label={t('common.delete')}>
<button type="button" onClick={() => onRemove(idx)} aria-label={t('common.delete')}
className="flex flex-none text-content-faint transition-colors hover:text-danger">
<X size={12} />
</button>
</Tooltip>
</div>
))}
</div>
)}
</div>
)
}
@@ -1,258 +0,0 @@
// FE-PLANNER-PLIMGPICK-001 to FE-PLANNER-PLIMGPICK-016: the editor's picture picker, the one
// place a place's picture is chosen: the upload tile, the current picture, the images
// attached to the place, the sources' photos, the credit line and "Remove image".
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { PlacePhotoCandidate } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import { placePictureRepo } from '../../repo/placePictureRepo';
import { normalizeImageFile } from '../../utils/convertHeic';
import PlaceImagePicker, { PhotoCredit, type PlaceImageOwn } from './PlaceImagePicker';
import type { ChosenImage } from './placeImageChoice';
vi.mock('../../repo/placePictureRepo', () => ({ placePictureRepo: { attachedImage: vi.fn() } }));
vi.mock('../../utils/convertHeic', () => ({
normalizeImageFile: vi.fn(async (file: File) => new File([file], `normalized-${file.name}`, { type: 'image/jpeg' })),
}));
/** Echo the key back, which keeps the assertions readable. */
const t = ((key: string) => key) as never;
const CURRENT = '/uploads/places/current.jpg';
const COMMONS: PlacePhotoCandidate = {
key: 'w~p0',
url: '/api/maps/place-photo/w~p0/bytes',
attribution: 'Alice',
license: 'CC BY-SA 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
sourceUrl: 'https://commons.wikimedia.org/wiki/File:A.jpg',
source: 'wikimedia',
} as PlacePhotoCandidate;
const GOOGLE: PlacePhotoCandidate = {
key: 'w~p1',
url: '/api/maps/place-photo/w~p1/bytes',
attribution: null,
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'google',
} as PlacePhotoCandidate;
const ATTACHED = buildTripFile({ id: 41, original_name: 'facade.png', mime_type: 'image/png', url: '/api/files/41' });
function own(over: Partial<PlaceImageOwn> = {}): PlaceImageOwn {
return {
currentUrl: null,
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
...over,
};
}
function renderPicker(
props: Partial<{
photos: PlacePhotoCandidate[];
chosen: ChosenImage;
own: PlaceImageOwn;
dialog: boolean;
}> = {}
) {
const onPickUrl = vi.fn();
const utils = render(
<PlaceImagePicker
photos={props.photos ?? []}
chosen={props.chosen ?? { kind: 'none' }}
onPickUrl={onPickUrl}
own={props.own}
dialog={props.dialog}
t={t}
/>
);
return { ...utils, onPickUrl };
}
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
beforeEach(() => {
vi.mocked(placePictureRepo.attachedImage).mockReset();
vi.mocked(placePictureRepo.attachedImage).mockResolvedValue('blob:attached');
vi.mocked(normalizeImageFile).mockClear();
});
describe('PlaceImagePicker', () => {
it('FE-PLANNER-PLIMGPICK-001: renders nothing while there is no picture to offer and no upload', () => {
const bare = renderPicker();
expect(bare.container).toBeEmptyDOMElement();
bare.unmount();
const readOnly = renderPicker({ own: own({ canUpload: false }) });
expect(readOnly.container).toBeEmptyDOMElement();
});
it('FE-PLANNER-PLIMGPICK-002: the upload tile opens the file picker and hands the converted file over', async () => {
const mine = own();
renderPicker({ own: mine });
const tile = screen.getByRole('button', { name: 'places.uploadImage' });
expect(tile).toHaveAttribute('aria-pressed', 'false');
const input = fileInput();
expect(input).toHaveAttribute('accept', 'image/*,.heic,.heif');
const openPicker = vi.spyOn(input, 'click');
fireEvent.click(tile);
expect(openPicker).toHaveBeenCalledTimes(1);
const picture = new File(['x'], 'photo.heic', { type: 'image/heic' });
fireEvent.change(input, { target: { files: [picture] } });
await waitFor(() => expect(mine.onUpload).toHaveBeenCalledTimes(1));
expect(normalizeImageFile).toHaveBeenCalledWith(picture);
expect(vi.mocked(mine.onUpload).mock.calls[0][0].name).toBe('normalized-photo.heic');
await waitFor(() => expect(tile).not.toBeDisabled());
});
it('FE-PLANNER-PLIMGPICK-003: an emptied file choice hands nothing over', () => {
const mine = own();
renderPicker({ own: mine });
fireEvent.change(fileInput(), { target: { files: [] } });
expect(normalizeImageFile).not.toHaveBeenCalled();
expect(mine.onUpload).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLIMGPICK-004: a picture waiting for the save shows as its preview, chosen and credited as yours', () => {
renderPicker({ own: own({ pendingPreview: 'blob:preview' }), chosen: { kind: 'upload' } });
const tile = screen.getByRole('button', { name: 'places.uploadImage' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:preview');
expect(screen.getByText('places.image.own')).toBeInTheDocument();
// A waiting upload can be dropped again.
expect(screen.getByRole('button', { name: 'places.removeImage' })).toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-005: the current picture is chosen by default, and a second tap removes it', () => {
const mine = own({ currentUrl: CURRENT });
renderPicker({ own: mine, chosen: { kind: 'current' } });
const tile = screen.getByRole('button', { name: 'places.image.current' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(tile.querySelector('img')).toHaveAttribute('src', CURRENT);
expect(screen.getByText('places.image.current')).toBeInTheDocument();
fireEvent.click(tile);
expect(mine.onRemove).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLIMGPICK-006: once dropped, the current picture is taken back with a tap', () => {
const mine = own({ currentUrl: CURRENT });
const { onPickUrl } = renderPicker({ own: mine, chosen: { kind: 'none' } });
// The line under the grid says what the save will do.
expect(screen.getByText('places.image.none')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'places.removeImage' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'places.image.current' }));
expect(onPickUrl).toHaveBeenCalledWith(CURRENT);
expect(mine.onRemove).not.toHaveBeenCalled();
});
it("FE-PLANNER-PLIMGPICK-007: a current picture that is one of the sources' photos is not shown twice", () => {
renderPicker({ photos: [COMMONS], own: own({ currentUrl: COMMONS.url }), chosen: { kind: 'current' } });
expect(screen.queryByRole('button', { name: 'places.image.current' })).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /places.details.pickImage/ })).toHaveLength(1);
});
it('FE-PLANNER-PLIMGPICK-008: an attached image loads through the file route and is picked with a tap', async () => {
const mine = own({ attached: [ATTACHED] });
renderPicker({ own: mine });
const tile = screen.getByRole('button', { name: 'facade.png' });
await waitFor(() => expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:attached'));
expect(placePictureRepo.attachedImage).toHaveBeenCalledWith('/api/files/41');
fireEvent.click(tile);
expect(mine.onPickAttached).toHaveBeenCalledWith(ATTACHED);
});
it('FE-PLANNER-PLIMGPICK-009: a chosen attached image is ticked and named under the grid; one that fails to load stays blank', async () => {
vi.mocked(placePictureRepo.attachedImage).mockRejectedValue(new Error('gone'));
renderPicker({ own: own({ attached: [ATTACHED] }), chosen: { kind: 'attached', fileId: 41 } });
const tile = screen.getByRole('button', { name: 'facade.png' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('facade.png')).toBeInTheDocument();
await waitFor(() => expect(placePictureRepo.attachedImage).toHaveBeenCalled());
expect(tile.querySelector('img')).toBeNull();
// Focusing it names it as well, and the name stays once it is left.
fireEvent.focus(tile);
expect(screen.getByText('facade.png')).toBeInTheDocument();
fireEvent.blur(tile);
expect(screen.getByText('facade.png')).toBeInTheDocument();
});
it("FE-PLANNER-PLIMGPICK-010: a source's photo is picked with a tap and let go with a second", () => {
const first = renderPicker({ photos: [COMMONS, GOOGLE] });
fireEvent.click(screen.getByRole('button', { name: 'places.details.pickImage: Alice' }));
expect(first.onPickUrl).toHaveBeenCalledWith(COMMONS.url);
first.unmount();
const chosen = renderPicker({ photos: [COMMONS, GOOGLE], chosen: { kind: 'url', url: GOOGLE.url } });
const tile = screen.getByRole('button', { name: 'places.details.pickImage: Google' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(tile);
expect(chosen.onPickUrl).toHaveBeenCalledWith(null);
});
it('FE-PLANNER-PLIMGPICK-011: the chosen photo is credited with its author and licence, each linked', () => {
renderPicker({ photos: [COMMONS, GOOGLE], chosen: { kind: 'url', url: COMMONS.url } });
expect(screen.getByText('Alice').closest('a')).toHaveAttribute('href', COMMONS.sourceUrl);
expect(screen.getByText('CC BY-SA 4.0').closest('a')).toHaveAttribute('href', COMMONS.licenseUrl);
// Hovering another tile moves the credit line to it, leaving moves it back.
const google = screen.getByRole('button', { name: 'places.details.pickImage: Google' });
fireEvent.mouseEnter(google);
expect(screen.getByText('Google')).toBeInTheDocument();
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
fireEvent.mouseLeave(google);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-012: without the own pictures the first photo is credited and nothing can be removed', () => {
renderPicker({ photos: [COMMONS], chosen: { kind: 'url', url: '/not/among/them.jpg' } });
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'places.removeImage' })).not.toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-013: "Remove image" leaves the place without a picture', () => {
const mine = own({ currentUrl: CURRENT });
renderPicker({ own: mine, photos: [COMMONS], chosen: { kind: 'url', url: COMMONS.url } });
fireEvent.click(screen.getByRole('button', { name: 'places.removeImage' }));
expect(mine.onRemove).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLIMGPICK-014: the sheet gives each photo a native tooltip, the dialog does not', () => {
const sheet = renderPicker({ photos: [COMMONS, GOOGLE] });
expect(screen.getByRole('button', { name: 'places.details.pickImage: Alice' })).toHaveAttribute(
'title',
'Alice, CC BY-SA 4.0'
);
expect(screen.getByRole('button', { name: 'places.details.pickImage: Google' })).toHaveAttribute('title', 'Google');
sheet.unmount();
renderPicker({ photos: [COMMONS], dialog: true });
expect(screen.getByRole('button', { name: 'places.details.pickImage: Alice' })).not.toHaveAttribute('title');
});
});
describe('PhotoCredit', () => {
it('FE-PLANNER-PLIMGPICK-015: a photo without links is still credited in plain text', () => {
render(<PhotoCredit photo={{ ...COMMONS, sourceUrl: null, licenseUrl: null }} />);
expect(screen.getByText('Alice').closest('a')).toBeNull();
expect(screen.getByText('CC BY-SA 4.0').closest('a')).toBeNull();
});
it('FE-PLANNER-PLIMGPICK-016: a photo without a licence shows only its credit', () => {
const { container } = render(<PhotoCredit photo={GOOGLE} />);
expect(container).toHaveTextContent(/^Google$/);
});
});
@@ -1,374 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
import { Check, ImageOff, ImagePlus, Loader2 } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { placePictureRepo } from '../../repo/placePictureRepo';
import type { TranslationFn, TripFile } from '../../types';
import { normalizeImageFile } from '../../utils/convertHeic';
import { Tooltip } from '../shared/Tooltip';
import { offersPictures, type ChosenImage } from './placeImageChoice';
import { creditOf } from './placePhotoCredit';
/** The place's own pictures beside the sources' ones: what it has, what it can get, what waits for the save. */
export interface PlaceImageOwn {
/** The picture the place has now; null on a new place or one without. */
currentUrl: string | null;
/** Images attached to the place that can stand as its picture. */
attached: TripFile[];
/** The picture from the device that waits for the save, as a preview. */
pendingPreview: string | null;
/** False where the user may not upload files; the upload tile is then left out. */
canUpload: boolean;
onUpload: (file: File) => void;
onPickAttached: (file: TripFile) => void;
onRemove: () => void;
}
interface PlaceImagePickerProps {
/** What the sources know, as found for the place; empty while they load or when there are none. */
photos: PlacePhotoCandidate[];
chosen: ChosenImage;
onPickUrl: (url: string | null) => void;
/** Left out where only the sources' pictures are offered. */
own?: PlaceImageOwn;
/** On a white card, without native tooltips: hovering moves the credit line instead. */
dialog?: boolean;
t: TranslationFn;
}
const TILE = 'group relative block aspect-square w-full overflow-hidden rounded-lg transition-shadow';
function ring(selected: boolean, dialog: boolean): string {
if (selected)
return `ring-2 ring-accent ring-offset-2 ${dialog ? 'ring-offset-surface-card' : 'ring-offset-surface-secondary'}`;
return 'ring-1 ring-edge hover:ring-content-muted';
}
function Picked(): React.ReactElement {
return (
<span className="absolute end-1 top-1 rounded-full bg-accent p-0.5 shadow-card">
<Check className="h-2.5 w-2.5 text-accent-on" />
</span>
);
}
/** What the credit line under the grid says: a source's photo with its author and licence, or the place's own. */
type Credit = { kind: 'photo'; photo: PlacePhotoCandidate } | { kind: 'own'; label: string } | null;
/**
* The one place a place's picture is chosen, in the editor: a tile to upload
* one from the device, the picture it has now, the images attached to it and
* the pictures its sources know. The chosen tile is ringed and ticked; a
* second tap on a source's photo lets go of it, and "Remove picture" leaves
* the place without one. Nothing is sent before the form is saved.
*/
export default function PlaceImagePicker({
photos,
chosen,
onPickUrl,
own,
dialog = false,
t,
}: PlaceImagePickerProps): React.ReactElement | null {
const [hovered, setHovered] = useState<Credit>(null);
const current = own?.currentUrl && !photos.some((p) => p.url === own.currentUrl) ? own.currentUrl : null;
const attached = own?.attached ?? [];
if (!offersPictures(photos, own)) return null;
const shown = hovered ?? creditFor(chosen, photos, attached, own, t);
const removable = !!own && chosen.kind !== 'none';
return (
<>
<div className="grid grid-cols-3 gap-1.5">
{own?.canUpload && (
<UploadTile
own={own}
selected={chosen.kind === 'upload'}
dialog={dialog}
onHover={(on) => setHovered(on ? { kind: 'own', label: t('places.image.own') } : null)}
t={t}
/>
)}
{current && (
<button
type="button"
onClick={() => (chosen.kind === 'current' ? own?.onRemove() : onPickUrl(current))}
aria-pressed={chosen.kind === 'current'}
aria-label={t('places.image.current')}
onMouseEnter={() => setHovered({ kind: 'own', label: t('places.image.current') })}
onMouseLeave={() => setHovered(null)}
className={`${TILE} ${ring(chosen.kind === 'current', dialog)}`}
>
<img src={current} alt="" className="h-full w-full object-cover" />
{chosen.kind === 'current' && <Picked />}
</button>
)}
{attached.map((file) => (
<AttachedTile
key={file.id}
file={file}
selected={chosen.kind === 'attached' && chosen.fileId === file.id}
onPick={() => own?.onPickAttached(file)}
onHover={(on) => setHovered(on ? { kind: 'own', label: file.original_name } : null)}
dialog={dialog}
/>
))}
{photos.map((photo) => (
<PhotoTile
key={photo.key}
photo={photo}
selected={chosen.kind === 'url' && chosen.url === photo.url}
onPick={onPickUrl}
onHover={(on) => setHovered(on ? { kind: 'photo', photo } : null)}
dialog={dialog}
t={t}
/>
))}
</div>
{(shown || removable) && (
<CreditLine shown={shown} onRemove={removable ? own?.onRemove : undefined} dialog={dialog} t={t} />
)}
</>
);
}
/** The tile that takes a picture from the device, or shows the one waiting for the save. */
function UploadTile({
own,
selected,
dialog,
onHover,
t,
}: {
own: PlaceImageOwn;
selected: boolean;
dialog: boolean;
onHover: (on: boolean) => void;
t: TranslationFn;
}): React.ReactElement {
const fileRef = useRef<HTMLInputElement>(null);
const [reading, setReading] = useState(false);
const pick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setReading(true);
try {
own.onUpload(await normalizeImageFile(file));
} finally {
setReading(false);
}
};
const preview = own.pendingPreview;
return (
<>
<Tooltip label={t('places.uploadImage')} placement="top">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={reading}
aria-pressed={selected}
aria-label={t('places.uploadImage')}
className={`${TILE} ${ring(selected, dialog)} ${preview ? '' : 'border border-dashed border-edge bg-surface-card'}`}
onMouseEnter={() => onHover(!!preview)}
onMouseLeave={() => onHover(false)}
>
{preview ? (
<>
<img src={preview} alt="" className="h-full w-full object-cover" />
{selected && <Picked />}
</>
) : (
<span className="flex h-full w-full flex-col items-center justify-center gap-1 px-1 text-center text-caption leading-tight text-content-muted group-hover:text-content">
{reading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" strokeWidth={2} />
)}
{t('places.uploadImage')}
</span>
)}
</button>
</Tooltip>
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" className="hidden" onChange={(e) => void pick(e)} />
</>
);
}
/** Who the chosen picture is from, and the way to leave the place without one. */
function CreditLine({
shown,
onRemove,
dialog,
t,
}: {
shown: Credit;
onRemove?: () => void;
dialog: boolean;
t: TranslationFn;
}): React.ReactElement {
return (
<div
className={`${dialog ? 'mt-2' : 'mt-1.5'} flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint`}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{shown?.kind === 'photo' && <PhotoCredit photo={shown.photo} />}
{shown?.kind === 'own' && <span className="min-w-0 truncate">{shown.label}</span>}
</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="inline-flex flex-none items-center gap-1 rounded-md px-1.5 py-0.5 text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
<ImageOff className="h-3 w-3" />
{t('places.removeImage')}
</button>
)}
</div>
);
}
/** The credit line for the chosen tile: a source's photo, the place's own picture, or nothing to say. */
function creditFor(
chosen: ChosenImage,
photos: PlacePhotoCandidate[],
attached: TripFile[],
own: PlaceImageOwn | undefined,
t: TranslationFn
): Credit {
if (chosen.kind === 'url') {
const photo = photos.find((p) => p.url === chosen.url);
if (photo) return { kind: 'photo', photo };
}
if (chosen.kind === 'current') return { kind: 'own', label: t('places.image.current') };
if (chosen.kind === 'upload') return { kind: 'own', label: t('places.image.own') };
if (chosen.kind === 'attached') {
const file = attached.find((f) => f.id === chosen.fileId);
if (file) return { kind: 'own', label: file.original_name };
}
if (chosen.kind === 'none' && own?.currentUrl) return { kind: 'own', label: t('places.image.none') };
return photos[0] ? { kind: 'photo', photo: photos[0] } : null;
}
function PhotoTile({
photo,
selected,
onPick,
onHover,
dialog,
t,
}: {
photo: PlacePhotoCandidate;
selected: boolean;
onPick: (url: string | null) => void;
onHover: (on: boolean) => void;
dialog: boolean;
t: TranslationFn;
}): React.ReactElement {
const credit = creditOf(photo);
return (
<button
type="button"
onClick={() => onPick(selected ? null : photo.url)}
onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)}
onFocus={() => onHover(true)}
onBlur={() => onHover(false)}
aria-pressed={selected}
aria-label={`${t('places.details.pickImage')}: ${credit}`}
title={dialog ? undefined : photo.license ? `${credit}, ${photo.license}` : credit}
className={`${TILE} ${ring(selected, dialog)}`}
>
<img
src={photo.url}
alt=""
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
/>
{selected && <Picked />}
</button>
);
}
/** An image attached to the place, loaded through the authenticated file route. */
function AttachedTile({
file,
selected,
onPick,
onHover,
dialog,
}: {
file: TripFile;
selected: boolean;
onPick: () => void;
onHover: (on: boolean) => void;
dialog: boolean;
}): React.ReactElement {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
let current = true;
placePictureRepo
.attachedImage(file.url)
.then((url) => {
if (current) setSrc(url);
})
.catch(() => {
if (current) setSrc(null);
});
return () => {
current = false;
};
}, [file.url]);
return (
<button
type="button"
onClick={onPick}
onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)}
onFocus={() => onHover(true)}
onBlur={() => onHover(false)}
aria-pressed={selected}
aria-label={file.original_name}
className={`${TILE} bg-surface-card ${ring(selected, dialog)}`}
>
{src && <img src={src} alt="" className="h-full w-full object-cover" />}
{selected && <Picked />}
</button>
);
}
/**
* Author and licence of the source's photo in play, each linked to its page.
*
* Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one
* without naming its author does not satisfy those terms. When a source hands
* us no author (Google), we say where it came from rather than inventing one.
*/
export function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElement {
const credit = creditOf(photo);
return (
<>
<span className="min-w-0 truncate">
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
</span>
{photo.license && (
<span className="flex-none">
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</span>
)}
</>
);
}
@@ -23,7 +23,6 @@ vi.mock('../../api/client', async (importOriginal) => {
mapsApi: {
details: vi.fn().mockResolvedValue({ place: null }),
placePhotoCredit: vi.fn().mockResolvedValue({ credit: null }),
placeEnrichment: vi.fn().mockResolvedValue({ photos: [], facts: [], description: null }),
},
};
});
@@ -55,7 +54,6 @@ beforeAll(() => {
import PlaceInspector from './PlaceInspector';
import { mapsApi } from '../../api/client';
import { __clearEnrichmentCacheForTests } from './placeEnrichment';
// ── Shared fixtures ───────────────────────────────────────────────────────────
@@ -102,8 +100,6 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } });
vi.mocked(mapsApi.details).mockResolvedValue({ place: null });
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({ photos: [], facts: [], description: null } as never);
__clearEnrichmentCacheForTests();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -974,21 +970,18 @@ describe('PlaceInspector', () => {
}
});
// ── The avatar opens the place's photos (#1136) ─────────────────────────────
// ── Custom thumbnail upload (#1136) ──────────────────────────────────────────
it('FE-PLANNER-INSPECTOR-049: the avatar opens the photos and offers no upload, the picture is changed in the editor', () => {
render(<PlaceInspector {...defaultProps} />);
expect(screen.getByRole('button', { name: 'Show photos' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
it('FE-PLANNER-INSPECTOR-049: onUploadImage in trip mode renders the upload-capable avatar', () => {
render(<PlaceInspector {...defaultProps} onUploadImage={vi.fn()} />);
// The place carries no image yet, so the avatar offers "Upload image".
expect(screen.getByRole('button', { name: 'Upload image' })).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-050: with no picture and nobody to ask for one the avatar stays a plain picture', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
it('FE-PLANNER-INSPECTOR-050: without onUploadImage the avatar has no upload control', () => {
render(<PlaceInspector {...defaultProps} />);
expect(screen.queryByRole('button', { name: 'Show photos' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
});
// ── Track colour (#776) ──────────────────────────────────────────────────────
@@ -1422,23 +1415,15 @@ describe('PlaceInspector', () => {
expect(document.querySelector('iframe[src*="tide-widget"]')).toBeNull();
});
// ── The avatar's gallery (#1136) ────────────────────────────────────────────
// ── Custom thumbnail callbacks (#1136) ───────────────────────────────────────
it('FE-PLANNER-INSPECTOR-088: the custom image opens at once in the gallery, and the avatar has no remove control', async () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
it('FE-PLANNER-INSPECTOR-088: removing the custom image clears image_url through onUpdatePlace', async () => {
const onUpdatePlace = vi.fn();
const onUploadImage = vi.fn(async () => {});
const withImage = buildPlace({ id: 706, name: 'Pictured', image_url: '/uploads/places/x.jpg' });
render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} />);
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = await screen.findByRole('dialog', { name: 'Show photos' });
expect(gallery.querySelector('img')).toHaveAttribute('src', '/uploads/places/x.jpg');
expect(mapsApi.placeEnrichment).not.toHaveBeenCalled();
expect(onUpdatePlace).not.toHaveBeenCalled();
fireEvent.click(within(gallery).getByRole('button', { name: 'Close' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Show photos' })).toBeNull());
render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} onUploadImage={onUploadImage} />);
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }));
expect(onUpdatePlace).toHaveBeenCalledWith(706, { image_url: null });
});
it('FE-PLANNER-INSPECTOR-090: opening hours are read for the selected day, not for today', async () => {
@@ -1560,28 +1545,13 @@ describe('PlaceInspector', () => {
}
});
it("FE-PLANNER-INSPECTOR-089: the avatar asks the sources for the place's photos on click and shows them credited", async () => {
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({
photos: [{
key: 'gp~p0', url: '/api/maps/place-photo/gp~p0/bytes', attribution: 'Alice', license: 'CC BY 4.0',
licenseUrl: null, sourceUrl: 'https://commons.wikimedia.org/wiki/File:T.jpg', source: 'wikimedia',
}],
facts: [],
description: null,
} as never);
render(<PlaceInspector {...defaultProps} />);
// Nothing is asked before the click: selecting a place is not worth the provider fan-out.
expect(mapsApi.placeEnrichment).not.toHaveBeenCalled();
expect(document.querySelector('input[type="file"][accept*="image"]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = await screen.findByRole('dialog', { name: 'Show photos' });
// The query, then the signal the repo passes on (placePictureRepo.ts).
expect(vi.mocked(mapsApi.placeEnrichment).mock.calls[0]?.[0]).toMatchObject({ lat: place.lat, lng: place.lng, name: 'Eiffel Tower' });
expect(within(gallery).getByRole('img')).toHaveAttribute('src', '/api/maps/place-photo/gp~p0/bytes');
expect(within(gallery).getByRole('link', { name: 'Alice, CC BY 4.0' })).toHaveAttribute(
'href', 'https://commons.wikimedia.org/wiki/File:T.jpg',
);
it('FE-PLANNER-INSPECTOR-089: picking a file hands it to onUploadImage', async () => {
const onUploadImage = vi.fn(async () => {});
render(<PlaceInspector {...defaultProps} onUploadImage={onUploadImage} />);
const input = document.querySelector('input[accept*="image"]') as HTMLInputElement;
const file = new File(['x'], 'thumb.png', { type: 'image/png' });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(onUploadImage).toHaveBeenCalledWith(place.id, file));
});
it('FE-PLANNER-INSPECTOR-098: deselecting and reselecting a place survives a rerender', async () => {
@@ -9,7 +9,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Clock, MapPin, ExternalLink, Phone, Mail, Banknote, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Star, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react'
import { hoursLines, periodsFromWeek } from './placeHours'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlacePhotoGallery from './PlacePhotoGallery'
import PlaceAvatarUpload, { pickableImages } from '../shared/PlaceAvatarUpload'
import { BlurredCode } from '../shared/BookingCode'
import PlaceRating from '../shared/StarRating'
import TrackColorPicker from '../shared/TrackColorPicker'
@@ -186,6 +186,10 @@ interface PlaceInspectorProps {
tripMembers?: TripMember[]
onSetParticipants?: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
/** Upload a custom thumbnail (#1136); enables the click-to-change avatar in trip mode. */
onUploadImage?: (placeId: number, file: File) => Promise<void>
/** Takes a picture already attached to the place as its image (#1242). */
onImageFromFile?: (placeId: number, fileId: number) => Promise<void>
/** Cast/clear the current user's star vote (#1435); enables the rating row. */
onRate?: (placeId: number, rating: number | null) => Promise<void> | void
leftWidth?: number
@@ -211,7 +215,7 @@ export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking,
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onRate,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onImageFromFile, onRate,
leftWidth = 0, rightWidth = 0, clearMapPill = false,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) {
@@ -388,6 +392,9 @@ export default function PlaceInspector({
editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue}
commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit}
onUpdatePlace={onUpdatePlace}
onUploadImage={mode === 'trip' && onUpdatePlace ? onUploadImage : undefined}
attachedImages={pickableImages(placeFiles)}
onImageFromFile={mode === 'trip' && onUpdatePlace ? onImageFromFile : undefined}
onClose={onClose} />
{/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */}
@@ -591,17 +598,19 @@ interface InspectorHeadProps {
handleNameKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
startNameEdit: () => void
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
onUploadImage?: (placeId: number, file: File) => Promise<void>
attachedImages: TripFile[]
onImageFromFile?: (placeId: number, fileId: number) => Promise<void>
onClose: () => void
}
/**
* The head band: the avatar with its open/closed ring and photo credit, the
* name (a double-click renames it), the address, and the facts as pills. The
* avatar opens the place's photos; its picture is changed in the editor.
* name (a double-click renames it), the address, and the facts as pills.
*/
function InspectorHead({ place, category, openNow, phone, email, rating, ratingCount, price, time, editingName, nameInputRef,
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onClose }: InspectorHeadProps) {
const { t, locale, language } = useTranslation()
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onUploadImage, attachedImages, onImageFromFile, onClose }: InspectorHeadProps) {
const { t, locale } = useTranslation()
const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm'
const hasCoords = !!(place.lat && place.lng)
const hasPills = openNow !== null || !!category || !!time || rating != null || !!price || place.source === 'dawarich' || !!phone || !!email || hasCoords
@@ -610,9 +619,13 @@ function InspectorHead({ place, category, openNow, phone, email, rating, ratingC
<div className="flex items-start gap-3.5">
<div className="flex w-[57px] flex-none flex-col items-center">
<div className={`rounded-full p-[2.5px] ${ring}`}>
<PlacePhotoGallery place={place} language={language} label={t('places.openPhotos')} className="rounded-full">
<PlaceAvatar place={place} category={category} size={52} />
</PlacePhotoGallery>
{onUploadImage
? <PlaceAvatarUpload place={place} category={category} size={52}
onUpload={(file: File) => onUploadImage(place.id, file)}
onRemove={() => onUpdatePlace(place.id, { image_url: null })}
attachedImages={attachedImages}
onPickAttached={onImageFromFile ? (fileId: number) => onImageFromFile(place.id, fileId) : undefined} />
: <PlaceAvatar place={place} category={category} size={52} />}
</div>
<PhotoCredit imageUrl={place.image_url} />
</div>
@@ -1,106 +0,0 @@
// FE-PLANNER-PHOTOGALLERY-001 to FE-PLANNER-PHOTOGALLERY-003: the place's picture as the
// way into its photos, on the desktop inspector and the phone sheet alike.
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import PlacePhotoGallery from './PlacePhotoGallery';
import { __clearEnrichmentCacheForTests } from './placeEnrichment';
const placeEnrichment = vi.fn();
// Partial: the store registry behind resetAllStores needs the real default client.
vi.mock('../../api/client', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/client')>()),
mapsApi: { placeEnrichment: (...a: unknown[]) => placeEnrichment(...a) },
}));
vi.mock('../Journey/VideoPlayerLazy', () => ({ default: () => null }));
const PLACE = buildPlace({ id: 3, name: 'Tower', lat: 48.85, lng: 2.29, image_url: '/uploads/places/tower.jpg' });
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { placesEnrichEnabled: true });
placeEnrichment.mockReset();
sessionStorage.clear();
__clearEnrichmentCacheForTests();
});
describe('PlacePhotoGallery', () => {
it('FE-PLANNER-PHOTOGALLERY-001: with nothing to open the picture stays a plain picture', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
render(
<PlacePhotoGallery place={{ ...PLACE, image_url: null }} language="en" label="Show photos">
<span data-testid="avatar" />
</PlacePhotoGallery>
);
expect(screen.getByTestId('avatar')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('FE-PLANNER-PHOTOGALLERY-002: the trigger is a labelled button that says it is busy while the sources answer', async () => {
let settle: (v: MapsPlaceEnrichmentResult) => void = () => {};
placeEnrichment.mockReturnValue(new Promise<MapsPlaceEnrichmentResult>((r) => (settle = r)));
render(
<PlacePhotoGallery
place={{ ...PLACE, image_url: null }}
language="en"
label="Show photos"
className="rounded-full"
>
<span data-testid="avatar" />
</PlacePhotoGallery>
);
const trigger = screen.getByRole('button', { name: 'Show photos' });
expect(trigger).toHaveClass('rounded-full');
expect(trigger).toContainElement(screen.getByTestId('avatar'));
expect(trigger).toHaveAttribute('aria-busy', 'false');
fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-busy', 'true');
expect(trigger).toHaveClass('opacity-70');
await act(async () =>
settle({
photos: [
{
key: 'k0',
url: '/api/maps/place-photo/k0/bytes',
attribution: 'Alice',
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'wikimedia',
},
],
facts: [],
description: null,
} as unknown as MapsPlaceEnrichmentResult)
);
expect(trigger).toHaveAttribute('aria-busy', 'false');
const gallery = screen.getByRole('dialog', { name: 'Show photos' });
expect(within(gallery).getByRole('img', { name: 'Alice' })).toHaveAttribute(
'src',
'/api/maps/place-photo/k0/bytes'
);
});
it('FE-PLANNER-PHOTOGALLERY-003: the gallery opens on the body, over everything, and closes again', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { container } = render(
<PlacePhotoGallery place={PLACE} language="en" label="Show photos">
<span data-testid="avatar" />
</PlacePhotoGallery>
);
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = screen.getByRole('dialog', { name: 'Show photos' });
expect(gallery.parentElement).toBe(document.body);
expect(container).not.toContainElement(gallery);
expect(gallery.querySelector('img')).toHaveAttribute('src', '/uploads/places/tower.jpg');
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'Show photos' })).not.toBeInTheDocument();
});
});
@@ -1,60 +0,0 @@
import { Loader2 } from 'lucide-react';
import React from 'react';
import { createPortal } from 'react-dom';
import type { Place } from '../../types';
import PhotoLightbox from '../Journey/PhotoLightbox';
import { Tooltip } from '../shared/Tooltip';
import { usePlaceGallery } from './usePlaceGallery';
interface PlacePhotoGalleryProps {
place: Place;
language: string;
/** What opens the gallery: the place's picture, or what stands in for it. */
children: React.ReactNode;
/** The open gallery's label, and the trigger's tooltip. */
label: string;
/** The trigger's shape, so its focus ring follows the picture (round on desktop, a rounded tile on the phone). */
className?: string;
}
/**
* The place's picture as the way into its photos: a click opens a full screen
* gallery of its own picture and what its sources know, each credited. While
* there is nothing to show the picture stays a plain picture. The lightbox is
* portalled to <body>: the inspector and the phone sheet sit inside
* transformed containers, where a fixed overlay would be trapped.
*/
export default function PlacePhotoGallery({
place,
language,
children,
label,
className = '',
}: PlacePhotoGalleryProps): React.ReactElement {
const { photos, open, close, isOpen, loading, canOpen } = usePlaceGallery(place, language);
if (!canOpen) return <>{children}</>;
return (
<>
<Tooltip label={label} placement="top">
<button
type="button"
onClick={open}
aria-label={label}
aria-busy={loading}
className={`relative block cursor-zoom-in transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${loading ? 'opacity-70' : ''} ${className}`}
>
{children}
{loading && (
<span className="absolute inset-0 grid place-items-center">
<Loader2 className="h-4 w-4 animate-spin text-content" />
</span>
)}
</button>
</Tooltip>
{isOpen &&
photos.length > 0 &&
createPortal(<PhotoLightbox photos={photos} onClose={close} label={label} />, document.body)}
</>
);
}
@@ -1,62 +0,0 @@
// FE-PLANNER-ENRICHCACHE-001 to FE-PLANNER-ENRICHCACHE-005: the session cache the editor's
// details column and the view's photo gallery share, so a place is asked about once.
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
__clearEnrichmentCacheForTests,
cacheKeyFor,
readCachedEnrichment,
storeEnrichment,
type PlaceDetailsSelection,
} from './placeEnrichment';
const SELECTION: PlaceDetailsSelection = { placeId: 'way:1', lat: 50.9, lng: 6.96, name: 'Museum Ludwig' };
const RESULT = { photos: [], facts: [], description: null } as unknown as MapsPlaceEnrichmentResult;
beforeEach(() => {
sessionStorage.clear();
__clearEnrichmentCacheForTests();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('placeEnrichment', () => {
it('FE-PLANNER-ENRICHCACHE-001: the key names the version, the place (or its coordinates) and the language', () => {
expect(cacheKeyFor(SELECTION, 'de')).toBe('enrich_v4_way:1_de');
expect(cacheKeyFor({ lat: 1.5, lng: 2, name: 'Pin' }, 'en')).toBe('enrich_v4_coords:1.5:2_en');
});
it('FE-PLANNER-ENRICHCACHE-002: nothing is cached before it is stored, then both caches hold it', () => {
expect(readCachedEnrichment(SELECTION, 'de')).toBeUndefined();
storeEnrichment(SELECTION, 'de', RESULT);
expect(readCachedEnrichment(SELECTION, 'de')).toBe(RESULT);
expect(JSON.parse(sessionStorage.getItem('enrich_v4_way:1_de') ?? '')).toEqual(RESULT);
// Another language is another answer.
expect(readCachedEnrichment(SELECTION, 'en')).toBeUndefined();
});
it('FE-PLANNER-ENRICHCACHE-003: an answer kept in the session outlives the in-memory cache', () => {
storeEnrichment(SELECTION, 'de', RESULT);
__clearEnrichmentCacheForTests();
expect(readCachedEnrichment(SELECTION, 'de')).toEqual(RESULT);
// Taken back into memory: the session is not read a second time.
sessionStorage.clear();
expect(readCachedEnrichment(SELECTION, 'de')).toEqual(RESULT);
});
it('FE-PLANNER-ENRICHCACHE-004: a broken session entry reads as nothing cached', () => {
sessionStorage.setItem(cacheKeyFor(SELECTION, 'de'), '{not json');
expect(readCachedEnrichment(SELECTION, 'de')).toBeUndefined();
});
it('FE-PLANNER-ENRICHCACHE-005: a full or blocked session storage still keeps the answer in memory', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
expect(() => storeEnrichment(SELECTION, 'de', RESULT)).not.toThrow();
expect(readCachedEnrichment(SELECTION, 'de')).toBe(RESULT);
});
});
@@ -1,77 +0,0 @@
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
/**
* The place an enrichment consumer is describing — the editor's details column
* and the view-mode photo gallery both use this shape.
*/
export interface PlaceDetailsSelection {
placeId?: string;
lat: number;
lng: number;
name: string;
/** The picked search result, so the server can skip its own details lookup. */
details?: Record<string, unknown>;
}
/**
* Module-level cache plus sessionStorage, same shape as usePlaceDetails in
* PlaceInspector. Clicking back and forth between two search results must not
* pay for the provider fan-out twice, and the gallery must reuse the pictures
* the editor already fetched for the same place.
*/
const enrichmentCache = new Map<string, MapsPlaceEnrichmentResult>();
/** Test seam: the module-level cache otherwise leaks between cases. */
export function __clearEnrichmentCacheForTests(): void {
enrichmentCache.clear();
}
function readSession(key: string): MapsPlaceEnrichmentResult | undefined {
try {
const raw = sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined;
} catch {
return undefined;
}
}
function writeSession(key: string, value: MapsPlaceEnrichmentResult): void {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
/* private mode / quota — the in-memory cache still helps for this session */
}
}
/**
* Bumped with the server's CACHE_VERSION. sessionStorage outlives a deploy, so
* without it the tab that was open while the fix shipped keeps replaying the
* answer the fix was about — and reports it as still broken.
*/
const ENRICH_CACHE_V = 4;
export function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string {
const id = selection.placeId || `coords:${selection.lat}:${selection.lng}`;
return `enrich_v${ENRICH_CACHE_V}_${id}_${language}`;
}
/** The answer if it was already fetched this session, else undefined. */
export function readCachedEnrichment(
selection: PlaceDetailsSelection,
language: string
): MapsPlaceEnrichmentResult | undefined {
const key = cacheKeyFor(selection, language);
const cached = enrichmentCache.get(key) ?? readSession(key);
if (cached) enrichmentCache.set(key, cached);
return cached;
}
export function storeEnrichment(
selection: PlaceDetailsSelection,
language: string,
value: MapsPlaceEnrichmentResult
): void {
const key = cacheKeyFor(selection, language);
enrichmentCache.set(key, value);
writeSession(key, value);
}
@@ -1,65 +0,0 @@
// FE-PLANNER-PLIMGCHOICE-001 to FE-PLANNER-PLIMGCHOICE-006: the editor's picture choice
// as a pure model, read the same way by the desktop dialog and the phone sheet.
import { describe, expect, it } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import { chosenImage, pendingImagePayload, pickableImages, type PendingPlaceImage } from './placeImageChoice';
const CURRENT = '/uploads/places/current.jpg';
describe('pickableImages', () => {
it('FE-PLANNER-PLIMGCHOICE-001: keeps the jpeg, png, gif and webp files and drops the rest', () => {
const jpg = buildTripFile({ mime_type: 'image/jpeg' });
const png = buildTripFile({ mime_type: 'image/png' });
const gif = buildTripFile({ mime_type: 'image/gif' });
const webp = buildTripFile({ mime_type: 'image/webp' });
const heic = buildTripFile({ mime_type: 'image/heic' });
const svg = buildTripFile({ mime_type: 'image/svg+xml' });
const pdf = buildTripFile({ mime_type: 'application/pdf' });
const unknown = buildTripFile({ mime_type: undefined as unknown as string });
expect(pickableImages([jpg, pdf, png, heic, gif, svg, webp, unknown])).toEqual([jpg, png, gif, webp]);
expect(pickableImages([])).toEqual([]);
});
});
describe('chosenImage', () => {
const upload: PendingPlaceImage = { kind: 'upload', file: new File(['x'], 'a.png'), preview: 'blob:a' };
const attached: PendingPlaceImage = { kind: 'attached', file: buildTripFile({ id: 77, mime_type: 'image/png' }) };
it('FE-PLANNER-PLIMGCHOICE-002: a waiting picture wins over whatever the field holds', () => {
expect(chosenImage('https://img.example/1.jpg', upload, CURRENT)).toEqual({ kind: 'upload' });
expect(chosenImage(null, attached, CURRENT)).toEqual({ kind: 'attached', fileId: 77 });
});
it('FE-PLANNER-PLIMGCHOICE-003: null drops the picture, whether the place had one or not', () => {
expect(chosenImage(null, null, CURRENT)).toEqual({ kind: 'none' });
expect(chosenImage(null, null, null)).toEqual({ kind: 'none' });
});
it("FE-PLANNER-PLIMGCHOICE-004: a URL is a source's photo, unless it is the picture the place already has", () => {
expect(chosenImage('https://img.example/1.jpg', null, CURRENT)).toEqual({
kind: 'url',
url: 'https://img.example/1.jpg',
});
expect(chosenImage(CURRENT, null, CURRENT)).toEqual({ kind: 'current' });
});
it('FE-PLANNER-PLIMGCHOICE-005: an untouched field keeps the current picture, or none on a place without one', () => {
expect(chosenImage(undefined, null, CURRENT)).toEqual({ kind: 'current' });
expect(chosenImage(undefined, null, null)).toEqual({ kind: 'none' });
expect(chosenImage(undefined, null, undefined)).toEqual({ kind: 'none' });
expect(chosenImage('', null, CURRENT)).toEqual({ kind: 'current' });
});
});
describe('pendingImagePayload', () => {
it("FE-PLANNER-PLIMGCHOICE-006: the save carries the file itself, or the attached file's id, or nothing", () => {
const file = new File(['x'], 'a.png');
expect(pendingImagePayload({ kind: 'upload', file, preview: 'blob:a' })).toBe(file);
expect(pendingImagePayload({ kind: 'attached', file: buildTripFile({ id: 9, mime_type: 'image/png' }) })).toEqual({
fileId: 9,
});
expect(pendingImagePayload(null)).toBeUndefined();
});
});
@@ -1,57 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
import type { TripFile } from '../../types';
import type { PlaceImageOwn } from './PlaceImagePicker';
const PICKABLE = /^image\/(jpeg|png|gif|webp)$/;
/** The images among a place's files that can stand as its picture (#1242). */
export function pickableImages(files: readonly TripFile[]): TripFile[] {
return files.filter((f) => PICKABLE.test(f.mime_type || ''));
}
/**
* A picture the form will make the place's own once it is saved: a file from
* the device, or an image already attached to the place. Nothing is sent
* before the save, so cancelling the form leaves the picture as it was, and a
* new place can get one too.
*/
export type PendingPlaceImage = { kind: 'upload'; file: File; preview: string } | { kind: 'attached'; file: TripFile };
/** What the picker shows as chosen: the tile that stands for what the save will do. */
export type ChosenImage =
| { kind: 'current' }
| { kind: 'none' }
| { kind: 'url'; url: string }
| { kind: 'upload' }
| { kind: 'attached'; fileId: number };
/**
* The choice the form holds, read as a tile. `imageUrl` is the form's field:
* undefined keeps the place's picture, null drops it, a URL takes a source's
* photo. A waiting picture wins over all of them.
*/
export function chosenImage(
imageUrl: string | null | undefined,
pending: PendingPlaceImage | null,
currentUrl: string | null | undefined
): ChosenImage {
if (pending) return pending.kind === 'upload' ? { kind: 'upload' } : { kind: 'attached', fileId: pending.file.id };
if (imageUrl === null) return { kind: 'none' };
if (imageUrl) return imageUrl === currentUrl ? { kind: 'current' } : { kind: 'url', url: imageUrl };
return currentUrl ? { kind: 'current' } : { kind: 'none' };
}
/** What travels with the save to put a waiting picture in place: the file, or the attached file's id. */
export type PendingImagePayload = File | { fileId: number };
export function pendingImagePayload(pending: PendingPlaceImage | null): PendingImagePayload | undefined {
if (!pending) return undefined;
return pending.kind === 'upload' ? pending.file : { fileId: pending.file.id };
}
/** Whether the picker has a tile to show: its sources' photos, or a picture of the place's own, or an upload. */
export function offersPictures(photos: readonly PlacePhotoCandidate[], own: PlaceImageOwn | undefined): boolean {
if (photos.length > 0) return true;
if (!own) return false;
return !!own.currentUrl || own.attached.length > 0 || !!own.pendingPreview || own.canUpload;
}
@@ -1,39 +0,0 @@
// FE-PLANNER-PHOTOCREDIT-001 to FE-PLANNER-PHOTOCREDIT-003: who a source's photo is credited to,
// and the one-line caption the gallery shows under it.
import type { PlacePhotoCandidate } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { creditOf, photoCaption, sourceLabelFor } from './placePhotoCredit';
function photo(over: Partial<PlacePhotoCandidate> = {}): PlacePhotoCandidate {
return {
key: 'k',
url: '/api/maps/place-photo/k/bytes',
attribution: 'Alice',
license: 'CC BY-SA 4.0',
licenseUrl: null,
sourceUrl: null,
source: 'wikimedia',
...over,
} as PlacePhotoCandidate;
}
describe('placePhotoCredit', () => {
it('FE-PLANNER-PHOTOCREDIT-001: each provider is named by its proper name', () => {
expect(sourceLabelFor('google')).toBe('Google');
expect(sourceLabelFor('wikipedia')).toBe('Wikipedia');
expect(sourceLabelFor('wikimedia')).toBe('Wikimedia Commons');
});
it('FE-PLANNER-PHOTOCREDIT-002: the author is credited, and the provider when there is none', () => {
expect(creditOf(photo())).toBe('Alice');
expect(creditOf(photo({ attribution: null, source: 'google' }))).toBe('Google');
expect(creditOf(photo({ attribution: '', source: 'wikipedia' }))).toBe('Wikipedia');
});
it('FE-PLANNER-PHOTOCREDIT-003: the caption joins the credit and the licence with a comma, not a dot', () => {
expect(photoCaption(photo())).toBe('Alice, CC BY-SA 4.0');
expect(photoCaption(photo({ attribution: null, license: null, source: 'google' }))).toBe('Google');
expect(photoCaption(photo()).includes('·')).toBe(false);
});
});
@@ -1,22 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
/** The name to put under a picture. Google gives no author, so it gets its own name. */
export function creditOf(photo: PlacePhotoCandidate): string {
return photo.attribution || sourceLabelFor(photo.source);
}
/**
* The provider a picture came from, for the pictures that hand us no author.
* Proper names, so they are not translated.
*/
export function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
if (source === 'google') return 'Google';
if (source === 'wikipedia') return 'Wikipedia';
return 'Wikimedia Commons';
}
/** Author and licence joined for a single-line caption, with a comma as the rest of the app writes it. */
export function photoCaption(photo: PlacePhotoCandidate): string {
const credit = creditOf(photo);
return photo.license ? `${credit}, ${photo.license}` : credit;
}
@@ -1,6 +1,6 @@
// FE-PLANNER-PLACEACT-001 to -015: the place writes behind both the desktop
// inspector and the phone place sheet.
import { act, renderHook } from '@testing-library/react';
import { act, renderHook, waitFor } from '@testing-library/react';
import type { ChangeEvent } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -8,12 +8,14 @@ import { buildAssignment, buildPlace } from '../../../tests/helpers/factories';
import { resetAllStores } from '../../../tests/helpers/store';
import { assignmentsApi } from '../../api/client';
import { useTripStore } from '../../store/tripStore';
import { normalizeImageFile } from '../../utils/convertHeic';
import {
participantsWith,
participantsWithout,
placeActions,
splitParticipants,
usePlaceFileUpload,
usePlaceImagePick,
} from './usePlaceActions';
vi.mock('../../utils/convertHeic', () => ({
@@ -206,3 +208,61 @@ describe('usePlaceFileUpload', () => {
expect(result.current.filesExpanded).toBe(false);
});
});
describe('usePlaceImagePick', () => {
it('FE-PLANNER-PLACEACT-013: converts the picked picture, uploads it and clears the input', async () => {
const upload = vi.fn(async (_file: File) => undefined);
const toast = { error: vi.fn() };
const { result } = renderHook(() => usePlaceImagePick(upload, toast));
const { event, target } = pick([new File(['x'], 'photo.heic')]);
await act(async () => {
await result.current.pickImage(event);
});
expect(normalizeImageFile).toHaveBeenCalledTimes(1);
expect(upload.mock.calls[0][0].name).toBe('normalized-photo.heic');
expect(target.value).toBe('');
expect(result.current.busy).toBe(false);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLACEACT-014: a refused picture is reported and the busy flag drops again', async () => {
let fail: (err: Error) => void = () => undefined;
const upload = vi.fn(() => new Promise<void>((_resolve, reject) => (fail = reject)));
const toast = { error: vi.fn() };
const { result } = renderHook(() => usePlaceImagePick(upload, toast));
let running: Promise<void> = Promise.resolve();
act(() => {
running = result.current.pickImage(pick([new File(['x'], 'a.png')]).event);
});
expect(result.current.busy).toBe(true);
await waitFor(() => expect(upload).toHaveBeenCalled());
await act(async () => {
fail(new Error('unsupported'));
await running;
});
expect(toast.error).toHaveBeenCalledWith('places.imageUploadError');
expect(result.current.busy).toBe(false);
});
it('FE-PLANNER-PLACEACT-015: without an upload or a file the pick only clears the input', async () => {
const upload = vi.fn(async () => undefined);
const noUpload = renderHook(() => usePlaceImagePick(undefined, { error: vi.fn() }));
const nothingPicked = renderHook(() => usePlaceImagePick(upload, { error: vi.fn() }));
const first = pick([new File(['x'], 'a.png')]);
const second = pick([]);
await act(async () => {
await noUpload.result.current.pickImage(first.event);
await nothingPicked.result.current.pickImage(second.event);
});
expect(first.target.value).toBe('');
expect(second.target.value).toBe('');
expect(upload).not.toHaveBeenCalled();
expect(normalizeImageFile).not.toHaveBeenCalled();
act(() => noUpload.result.current.setBusy(true));
expect(noUpload.result.current.busy).toBe(true);
});
});
@@ -4,6 +4,7 @@ import { translateApiError, useTranslation } from '../../i18n';
import type { TripStoreState } from '../../store/tripStore';
import { useTripStore } from '../../store/tripStore';
import type { Place } from '../../types';
import { normalizeImageFile } from '../../utils/convertHeic';
type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -132,3 +133,29 @@ export function usePlaceFileUpload(
return { uploading, filesExpanded, setFilesExpanded, fileInputRef, handleUpload };
}
/**
* A picture picked as a place's image (#1136): converted from HEIC where needed,
* uploaded, and a failure said in a toast. `busy` also covers whatever else the
* caller does to the image while it runs. Without an upload the pick does nothing.
*/
export function usePlaceImagePick(upload: ((file: File) => Promise<unknown>) | undefined, toast: PlaceActionToast) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const pickImage = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !upload) return;
setBusy(true);
try {
await upload(await normalizeImageFile(file));
} catch (err: unknown) {
toast.error(translateApiError(t, err, 'places.imageUploadError'));
} finally {
setBusy(false);
}
};
return { busy, setBusy, pickImage };
}
@@ -1,11 +1,9 @@
// FE-PLANNER-PLFORM-001 to FE-PLANNER-PLFORM-019: the place form state behind the desktop
// dialog and the phone sheet, its waiting picture, and the inline new category of both
// ('dialog' and 'sheet').
// FE-PLANNER-PLFORM-001 to FE-PLANNER-PLFORM-014: the place form state behind the desktop
// dialog and the phone sheet, and the inline new category of both ('dialog' and 'sheet').
import { act, renderHook } from '@testing-library/react';
import type React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import type { Category, Place } from '../../types';
import { DEFAULT_FORM } from './PlaceFormModal.helpers';
import { useNewCategory, usePlaceForm, type NewCategoryOptions } from './usePlaceForm';
@@ -165,88 +163,6 @@ function category(id: number): Category {
return { id, name: 'Food', color: '#6366f1', icon: 'MapPin' } as unknown as Category;
}
describe('usePlaceForm, the waiting picture', () => {
let previews: number;
let createObjectURL: MockInstance<typeof URL.createObjectURL>;
let revokeObjectURL: MockInstance<typeof URL.revokeObjectURL>;
beforeEach(() => {
previews = 0;
createObjectURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:preview-${++previews}`);
revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('FE-PLANNER-PLFORM-015: nothing waits on a fresh form', () => {
const { result } = setup();
expect(result.current.pendingImage).toBeNull();
});
it('FE-PLANNER-PLFORM-016: a picture from the device waits with its preview and keeps the field untouched', () => {
const { result } = setup();
act(() => result.current.pickImage('https://img.example/1.jpg'));
const picture = file('front.png', 'image/png');
act(() => result.current.stageUpload(picture));
expect(createObjectURL).toHaveBeenCalledWith(picture);
expect(result.current.pendingImage).toEqual({ kind: 'upload', file: picture, preview: 'blob:preview-1' });
// A source's photo picked before gives way to it.
expect(result.current.form.image_url).toBeUndefined();
});
it('FE-PLANNER-PLFORM-017: replacing the waiting upload frees its preview, whatever replaces it', () => {
const { result } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
act(() => result.current.stageUpload(file('b.png', 'image/png')));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
expect(result.current.pendingImage).toMatchObject({ kind: 'upload', preview: 'blob:preview-2' });
const attached = buildTripFile({ id: 8, mime_type: 'image/png' });
act(() => result.current.stageAttached(attached));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-2');
expect(result.current.pendingImage).toEqual({ kind: 'attached', file: attached });
expect(result.current.form.image_url).toBeUndefined();
// An attached image has no preview of ours to free.
revokeObjectURL.mockClear();
act(() => result.current.pickImage('https://img.example/2.jpg'));
expect(revokeObjectURL).not.toHaveBeenCalled();
expect(result.current.pendingImage).toBeNull();
expect(result.current.form.image_url).toBe('https://img.example/2.jpg');
});
it('FE-PLANNER-PLFORM-018: removing the picture drops what waits and empties the field for the save', () => {
const { result } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
act(() => result.current.removeImage());
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
expect(result.current.pendingImage).toBeNull();
expect(result.current.form.image_url).toBeNull();
// A fresh opening starts without a waiting picture as well.
act(() => result.current.stageUpload(file('b.png', 'image/png')));
act(() => result.current.openForm(DEFAULT_FORM, null, null));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-2');
expect(result.current.pendingImage).toBeNull();
});
it('FE-PLANNER-PLFORM-019: closing the form frees the preview still waiting, and only that one', () => {
const { result, unmount } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
revokeObjectURL.mockClear();
unmount();
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
const empty = setup();
revokeObjectURL.mockClear();
empty.unmount();
expect(revokeObjectURL).not.toHaveBeenCalled();
});
});
describe('useNewCategory', () => {
function setupCategory(options: Pick<NewCategoryOptions, 'variant' | 'create'>) {
const onCreated = vi.fn();
+4 -45
View File
@@ -1,8 +1,8 @@
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import type { useTranslation } from '../../i18n';
import type { Category, TripFile } from '../../types';
import type { Category } from '../../types';
import { offlineOr } from '../../utils/needsConnection';
import type { useToast } from '../shared/Toast';
import type { PlaceDetailsSelection } from './PlaceDetailsColumn';
@@ -15,7 +15,6 @@ import {
type PlacePrefill,
type ResultField,
} from './PlaceFormModal.helpers';
import type { PendingPlaceImage } from './placeImageChoice';
type Translate = ReturnType<typeof useTranslation>['t'];
type Toast = ReturnType<typeof useToast>;
@@ -41,20 +40,6 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
// user's and survives. See mergeResult.
const autoFilledRef = useRef<Set<ResultField>>(new Set());
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
// The picture that becomes the place's own when the form is saved.
const [pendingImage, setPendingImageState] = useState<PendingPlaceImage | null>(null);
const previewRef = useRef<string | null>(null);
const setPendingImage = (next: PendingPlaceImage | null) => {
if (previewRef.current) URL.revokeObjectURL(previewRef.current);
previewRef.current = next?.kind === 'upload' ? next.preview : null;
setPendingImageState(next);
};
useEffect(
() => () => {
if (previewRef.current) URL.revokeObjectURL(previewRef.current);
},
[]
);
const [isSaving, setIsSaving] = useState(false);
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null);
// What the details column is describing. Null until there is a place to describe.
@@ -74,7 +59,6 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
autoFilledRef.current = openingAutoFilled(place, prefill);
setDetailsSelection(openingDetailsSelection(place, prefill));
setPendingFiles([]);
setPendingImage(null);
setDuplicateWarning(null);
};
@@ -107,29 +91,8 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
}
};
/** A source's photo picked in the details column, or the place's own picture again. */
const pickImage = (url: string | null) => {
setPendingImage(null);
setForm((prev) => ({ ...prev, image_url: url ?? undefined }));
};
/** A picture from the device, shown at once and uploaded when the form is saved. */
const stageUpload = (file: File) => {
setPendingImage({ kind: 'upload', file, preview: URL.createObjectURL(file) });
setForm((prev) => ({ ...prev, image_url: undefined }));
};
/** An image already attached to the place, made its picture when the form is saved. */
const stageAttached = (file: TripFile) => {
setPendingImage({ kind: 'attached', file });
setForm((prev) => ({ ...prev, image_url: undefined }));
};
/** No picture at all: the save drops the place's own one. */
const removeImage = () => {
setPendingImage(null);
setForm((prev) => ({ ...prev, image_url: null }));
};
/** A hero image picked in the details column, or none. */
const pickImage = (url: string | null) => setForm((prev) => ({ ...prev, image_url: url ?? undefined }));
/** The details column's description, taken over into the form. */
const adoptDescription = (text: string) => setForm((prev) => ({ ...prev, description: text }));
@@ -164,10 +127,6 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
openForm,
handleChange,
pickImage,
pendingImage,
stageUpload,
stageAttached,
removeImage,
adoptDescription,
warnIfDuplicate,
};
@@ -1,227 +0,0 @@
// FE-PLANNER-PLGALLERY-001 to FE-PLANNER-PLGALLERY-011: the pictures of one place for the
// view's full screen gallery: its own picture first, the sources' photos asked for on
// open, the shared session cache, and an answer that comes back for a place already left.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { MapsPlaceEnrichmentResult, PlacePhotoCandidate } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import type { Place } from '../../types';
import { __clearEnrichmentCacheForTests, readCachedEnrichment, storeEnrichment } from './placeEnrichment';
import { usePlaceGallery } from './usePlaceGallery';
const placeEnrichment = vi.fn();
vi.mock('../../repo/placePictureRepo', () => ({
placePictureRepo: { enrichment: (...a: unknown[]) => placeEnrichment(...a) },
}));
// One toast for every render, the way the real hook memoises it.
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const CUSTOM = '/uploads/places/own.jpg';
const PHOTO: PlacePhotoCandidate = {
key: 'gp~p0',
url: '/api/maps/place-photo/gp~p0/bytes',
attribution: 'Alice',
license: 'CC BY 4.0',
licenseUrl: null,
sourceUrl: 'https://commons.wikimedia.org/wiki/File:A.jpg',
source: 'wikimedia',
} as PlacePhotoCandidate;
const GOOGLE: PlacePhotoCandidate = {
key: 'gp~p1',
url: '/api/maps/place-photo/gp~p1/bytes',
attribution: null,
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'google',
} as PlacePhotoCandidate;
const result = (photos: PlacePhotoCandidate[]) =>
({ photos, facts: [], description: null }) as unknown as MapsPlaceEnrichmentResult;
const LAKE = buildPlace({ id: 1, name: 'Lake', lat: 47.5, lng: 11.2, google_place_id: 'gp-lake' });
const CASTLE = buildPlace({ id: 2, name: 'Castle', lat: 48.1, lng: 11.6, osm_id: 'way:2' });
function deferred<T>() {
let resolve: (v: T) => void = () => {};
let reject: (e: unknown) => void = () => {};
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function setup(place: Place, language = 'de') {
return renderHook(({ p, lang }) => usePlaceGallery(p, lang), { initialProps: { p: place, lang: language } });
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { placesEnrichEnabled: true });
placeEnrichment.mockReset();
placeEnrichment.mockResolvedValue(result([PHOTO]));
sessionStorage.clear();
__clearEnrichmentCacheForTests();
Object.values(toast).forEach((fn) => fn.mockClear());
});
describe('usePlaceGallery', () => {
it('FE-PLANNER-PLGALLERY-001: it can open with a picture of its own, or with coordinates the sources can be asked about', () => {
expect(setup({ ...LAKE, image_url: CUSTOM }).result.current.canOpen).toBe(true);
expect(setup(LAKE).result.current.canOpen).toBe(true);
expect(setup({ ...LAKE, lat: null, lng: null } as unknown as Place).result.current.canOpen).toBe(false);
expect(setup({ ...LAKE, lat: 'north' as unknown as number }).result.current.canOpen).toBe(false);
seedStore(useAuthStore, { placesEnrichEnabled: false });
expect(setup(LAKE).result.current.canOpen).toBe(false);
expect(setup({ ...LAKE, image_url: CUSTOM }).result.current.canOpen).toBe(true);
});
it('FE-PLANNER-PLGALLERY-002: with the sources switched off its own picture opens at once, alone', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { result: r } = setup({ ...LAKE, image_url: CUSTOM });
expect(r.current.photos).toEqual([{ id: 'custom', src: CUSTOM, caption: null, captionHref: null }]);
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(placeEnrichment).not.toHaveBeenCalled();
act(() => r.current.close());
expect(r.current.isOpen).toBe(false);
});
it('FE-PLANNER-PLGALLERY-003: nothing of its own and nobody to ask says so instead of opening', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { result: r } = setup(LAKE);
act(() => r.current.open());
expect(r.current.isOpen).toBe(false);
expect(toast.info).toHaveBeenCalledWith('places.details.noPhotos');
});
it('FE-PLANNER-PLGALLERY-004: the sources are asked on open, and their photos join with a linked credit', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r } = setup(LAKE);
expect(placeEnrichment).not.toHaveBeenCalled();
act(() => r.current.open());
expect(r.current.loading).toBe(true);
expect(r.current.isOpen).toBe(false);
expect(placeEnrichment).toHaveBeenCalledWith({
placeId: 'gp-lake',
lat: 47.5,
lng: 11.2,
name: 'Lake',
lang: 'de',
});
await act(async () => pending.resolve(result([PHOTO, GOOGLE])));
expect(r.current.loading).toBe(false);
expect(r.current.isOpen).toBe(true);
expect(r.current.photos).toEqual([
{ id: 'gp~p0', src: PHOTO.url, caption: 'Alice, CC BY 4.0', captionHref: PHOTO.sourceUrl },
{ id: 'gp~p1', src: GOOGLE.url, caption: 'Google', captionHref: null },
]);
// Kept for the editor's details column and the next open.
expect(readCachedEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de')).toBeTruthy();
});
it('FE-PLANNER-PLGALLERY-005: a second open uses the answer it already has', async () => {
const { result: r } = setup(LAKE);
act(() => r.current.open());
await waitFor(() => expect(r.current.isOpen).toBe(true));
act(() => r.current.close());
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(placeEnrichment).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLGALLERY-006: its own picture leads, and a source photo of the same picture is not shown twice', async () => {
placeEnrichment.mockResolvedValue(result([PHOTO, GOOGLE]));
const { result: r } = setup({ ...LAKE, image_url: PHOTO.url });
act(() => r.current.open());
// Open at once on its own picture, while the sources are still asked.
expect(r.current.isOpen).toBe(true);
expect(r.current.loading).toBe(true);
await waitFor(() => expect(r.current.loading).toBe(false));
expect(r.current.photos.map((p) => p.id)).toEqual(['custom', 'gp~p1']);
});
it('FE-PLANNER-PLGALLERY-007: when the sources have nothing either, it says so, the second time too', async () => {
placeEnrichment.mockResolvedValue(result([]));
const { result: r } = setup(CASTLE);
act(() => r.current.open());
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('places.details.noPhotos'));
expect(r.current.isOpen).toBe(false);
// The OSM id is what the server is asked about when there is no Google one.
expect(placeEnrichment.mock.calls[0][0]).toMatchObject({ placeId: 'way:2' });
act(() => r.current.open());
expect(toast.info).toHaveBeenCalledTimes(2);
expect(placeEnrichment).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLGALLERY-008: an answer this session already holds opens without asking', () => {
storeEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de', result([PHOTO]));
const { result: r } = setup(LAKE);
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(r.current.loading).toBe(false);
expect(placeEnrichment).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLGALLERY-009: a failed request is reported, unless its own picture is already on screen', async () => {
placeEnrichment.mockRejectedValue(new Error('network'));
const bare = setup(LAKE);
act(() => bare.result.current.open());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('places.details.error'));
expect(bare.result.current.loading).toBe(false);
bare.unmount();
toast.error.mockClear();
const pictured = setup({ ...CASTLE, image_url: CUSTOM });
act(() => pictured.result.current.open());
await waitFor(() => expect(pictured.result.current.loading).toBe(false));
expect(pictured.result.current.isOpen).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLGALLERY-010: switching place while the sources answer drops that answer: nothing opens, nothing joins', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r, rerender } = setup(LAKE);
act(() => r.current.open());
expect(r.current.loading).toBe(true);
rerender({ p: CASTLE, lang: 'de' });
expect(r.current.loading).toBe(false);
await act(async () => pending.resolve(result([PHOTO])));
expect(r.current.isOpen).toBe(false);
expect(r.current.loading).toBe(false);
expect(r.current.photos).toEqual([]);
expect(toast.error).not.toHaveBeenCalled();
// The answer still belongs to the first place, so it is kept for it.
expect(readCachedEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de')).toBeTruthy();
});
it('FE-PLANNER-PLGALLERY-011: a failure that arrives after the switch is not reported over the next place', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r, rerender } = setup(LAKE);
act(() => r.current.open());
act(() => r.current.close());
rerender({ p: CASTLE, lang: 'de' });
await act(async () => pending.reject(new Error('network')));
expect(toast.error).not.toHaveBeenCalled();
expect(r.current.isOpen).toBe(false);
});
});
@@ -1,136 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { translateApiError, useTranslation } from '../../i18n';
import { placePictureRepo } from '../../repo/placePictureRepo';
import { useAuthStore } from '../../store/authStore';
import type { Place } from '../../types';
import { useToast } from '../shared/Toast';
import { readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment';
import { photoCaption } from './placePhotoCredit';
/** What the lightbox renders; mirrors Journey/PhotoLightbox's private photo shape. */
interface GalleryPhoto {
id: string;
src: string;
caption: string | null;
/** The photo's source page, which the credit links to. */
captionHref: string | null;
}
interface UsePlaceGallery {
photos: GalleryPhoto[];
open: () => void;
close: () => void;
isOpen: boolean;
/** Loading enrichment; the trigger can show a spinner while it waits. */
loading: boolean;
/** False when there is no picture and nobody who could supply one. */
canOpen: boolean;
}
/**
* The pictures of one place, for the full-screen view-mode gallery.
*
* The custom image (if any) leads, then the provider candidates the editor's
* details column already uses. Enrichment is fetched lazily on open: fanning
* out to Google/Wikimedia for every place a user merely selects is not worth
* it, and the shared session cache makes a second open instant.
*/
export function usePlaceGallery(place: Place, language: string): UsePlaceGallery {
const placesEnrichEnabled = useAuthStore((s) => s.placesEnrichEnabled);
const { t } = useTranslation();
const toast = useToast();
// Each piece of state names the place it belongs to: a different place reads
// as nothing loaded, nothing open, so the previous answer never flashes.
const [candidatesFor, setCandidatesFor] = useState<{ placeId: number; list: PlacePhotoCandidate[] } | null>(null);
const [openFor, setOpenFor] = useState<number | null>(null);
const [loadingFor, setLoadingFor] = useState<number | null>(null);
const placeId = place.id;
const candidates = candidatesFor?.placeId === placeId ? candidatesFor.list : null;
const isOpen = openFor === placeId;
const loading = loadingFor === placeId;
// The place on screen now, for an answer that arrives after the user moved on.
const shownRef = useRef(placeId);
useEffect(() => {
shownRef.current = placeId;
}, [placeId]);
const selection = useMemo<PlaceDetailsSelection | null>(() => {
if (place.lat == null || place.lng == null) return null;
const lat = Number(place.lat);
const lng = Number(place.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return {
placeId: place.google_place_id || place.amap_poi_id || place.osm_id || undefined,
lat,
lng,
name: place.name || '',
};
}, [place.google_place_id, place.amap_poi_id, place.osm_id, place.lat, place.lng, place.name]);
const photos = useMemo<GalleryPhoto[]>(() => {
const list: GalleryPhoto[] = [];
if (place.image_url) list.push({ id: 'custom', src: place.image_url, caption: null, captionHref: null });
for (const candidate of candidates ?? []) {
if (list.some((p) => p.src === candidate.url)) continue;
list.push({
id: candidate.key,
src: candidate.url,
caption: photoCaption(candidate),
captionHref: candidate.sourceUrl ?? null,
});
}
return list;
}, [place.image_url, candidates]);
const canOpen = !!place.image_url || (placesEnrichEnabled && !!selection);
const close = useCallback(() => setOpenFor(null), []);
const open = useCallback(() => {
// The custom image is already on screen — open now and let any provider
// pictures join it as they arrive.
if (place.image_url) setOpenFor(placeId);
if (!placesEnrichEnabled || !selection) {
if (!place.image_url) toast.info(t('places.details.noPhotos'));
return;
}
if (candidates !== null) {
if (place.image_url || candidates.length) setOpenFor(placeId);
else toast.info(t('places.details.noPhotos'));
return;
}
const apply = (list: PlacePhotoCandidate[]) => {
setCandidatesFor({ placeId, list });
if (place.image_url || list.length) setOpenFor(placeId);
else toast.info(t('places.details.noPhotos'));
};
const cached = readCachedEnrichment(selection, language);
if (cached) {
apply(cached.photos);
return;
}
// An answer for a place the user has left is kept for that place, but opens nothing and says nothing.
const current = () => shownRef.current === placeId;
setLoadingFor(placeId);
placePictureRepo
.enrichment({ ...selection, lang: language })
.then((result) => {
storeEnrichment(selection, language, result);
if (current()) apply(result.photos);
else setCandidatesFor({ placeId, list: result.photos });
})
.catch((err: unknown) => {
if (current() && !place.image_url) toast.error(translateApiError(t, err, 'places.details.error'));
})
.finally(() => {
setLoadingFor((now) => (now === placeId ? null : now));
});
}, [place.image_url, placeId, placesEnrichEnabled, selection, candidates, language, t, toast]);
return { photos, open, close, isOpen, loading, canOpen };
}
@@ -1,106 +0,0 @@
// FE-PLANNER-PLIMGFIELD-001 to FE-PLANNER-PLIMGFIELD-004: the editor's picture field the
// desktop dialog and the phone sheet share: the place's attached images, the waiting
// picture's preview, and which tile stands for the choice.
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildReservation, buildTripFile } from '../../../tests/helpers/factories';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTripStore } from '../../store/tripStore';
import type { TripFile } from '../../types';
import type { PendingPlaceImage } from './placeImageChoice';
import { usePlaceImageField } from './usePlaceImageField';
const PHOTO = buildTripFile({ id: 1, place_id: 5, mime_type: 'image/jpeg', original_name: 'front.jpg' });
const PDF = buildTripFile({ id: 2, place_id: 5, mime_type: 'application/pdf' });
const ELSEWHERE = buildTripFile({ id: 3, place_id: 6, mime_type: 'image/png' });
const ON_BOOKING = buildTripFile({
id: 4,
place_id: null,
reservation_id: 90,
mime_type: 'image/webp',
} as Partial<TripFile>);
const handlers = () => ({ stageUpload: vi.fn(), stageAttached: vi.fn(), removeImage: vi.fn() });
beforeEach(() => {
resetAllStores();
useTripStore.setState({
files: [PHOTO, PDF, ELSEWHERE, ON_BOOKING],
reservations: [buildReservation({ id: 90, place_id: 5 })],
} as never);
});
describe('usePlaceImageField', () => {
it("FE-PLANNER-PLIMGFIELD-001: offers the place's attached images, its booking's included, and its current picture", () => {
const h = handlers();
const { result } = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: undefined,
pendingImage: null,
canUploadFiles: true,
...h,
})
);
expect(result.current.own.attached).toEqual([PHOTO, ON_BOOKING]);
expect(result.current.own.currentUrl).toBe('/uploads/places/5.jpg');
expect(result.current.own.pendingPreview).toBeNull();
expect(result.current.own.canUpload).toBe(true);
expect(result.current.own.onUpload).toBe(h.stageUpload);
expect(result.current.own.onPickAttached).toBe(h.stageAttached);
expect(result.current.own.onRemove).toBe(h.removeImage);
expect(result.current.chosen).toEqual({ kind: 'current' });
});
it('FE-PLANNER-PLIMGFIELD-002: a new place has nothing attached and no picture, but can still be given one', () => {
const { result } = renderHook(() =>
usePlaceImageField({ place: null, imageUrl: undefined, pendingImage: null, canUploadFiles: true, ...handlers() })
);
expect(result.current.own.attached).toEqual([]);
expect(result.current.own.currentUrl).toBeNull();
expect(result.current.own.canUpload).toBe(true);
expect(result.current.chosen).toEqual({ kind: 'none' });
});
it('FE-PLANNER-PLIMGFIELD-003: a waiting upload shows as its preview and is the choice', () => {
const pending: PendingPlaceImage = { kind: 'upload', file: new File(['x'], 'a.png'), preview: 'blob:a' };
const { result } = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: null },
imageUrl: undefined,
pendingImage: pending,
canUploadFiles: false,
...handlers(),
})
);
expect(result.current.own.pendingPreview).toBe('blob:a');
expect(result.current.own.canUpload).toBe(false);
expect(result.current.chosen).toEqual({ kind: 'upload' });
});
it('FE-PLANNER-PLIMGFIELD-004: a waiting attached image has no preview, and a dropped picture reads as none', () => {
const attached = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: undefined,
pendingImage: { kind: 'attached', file: PHOTO },
canUploadFiles: true,
...handlers(),
})
);
expect(attached.result.current.own.pendingPreview).toBeNull();
expect(attached.result.current.chosen).toEqual({ kind: 'attached', fileId: 1 });
const dropped = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: null,
pendingImage: null,
canUploadFiles: true,
...handlers(),
})
);
expect(dropped.result.current.chosen).toEqual({ kind: 'none' });
});
});
@@ -1,57 +0,0 @@
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTripStore } from '../../store/tripStore';
import type { Place, TripFile } from '../../types';
import { filesForPlace } from '../../utils/placeFiles';
import type { PlaceImageOwn } from './PlaceImagePicker';
import { chosenImage, pickableImages, type ChosenImage, type PendingPlaceImage } from './placeImageChoice';
interface PlaceImageFieldOptions {
/** The place being edited; null or undefined while a new one is made. */
place: Pick<Place, 'id' | 'image_url'> | null | undefined;
/** The form's image field: undefined keeps the picture, null drops it, a URL takes a source's photo. */
imageUrl: string | null | undefined;
pendingImage: PendingPlaceImage | null;
canUploadFiles: boolean;
stageUpload: (file: File) => void;
stageAttached: (file: TripFile) => void;
removeImage: () => void;
}
/**
* The editor's picture of a place, the same for the desktop dialog and the
* phone sheet: the picture it has now, the images attached to it, the one
* waiting for the save, and which tile of the picker stands for the choice.
* A new place has no attached files yet; it can still be given a picture from
* the device, uploaded once the save has made it.
*/
export function usePlaceImageField({
place,
imageUrl,
pendingImage,
canUploadFiles,
stageUpload,
stageAttached,
removeImage,
}: PlaceImageFieldOptions): { own: PlaceImageOwn; chosen: ChosenImage } {
const { files, reservations } = useTripStore(useShallow((s) => ({ files: s.files, reservations: s.reservations })));
const placeId = place?.id;
const attached = useMemo(
() => (placeId ? pickableImages(filesForPlace(files, placeId, reservations)) : []),
[files, placeId, reservations]
);
const currentUrl = place?.image_url ?? null;
return {
own: {
currentUrl,
attached,
pendingPreview: pendingImage?.kind === 'upload' ? pendingImage.preview : null,
canUpload: canUploadFiles,
onUpload: stageUpload,
onPickAttached: stageAttached,
onRemove: removeImage,
},
chosen: chosenImage(imageUrl, pendingImage, currentUrl),
};
}
@@ -1,17 +1,35 @@
import { MAX_WAY_PINS, pinWay, type PinnedWay, type WayPin } from '@trek/shared'
import type { RoadLine } from '../Map/RouteCalculator'
import { furthestFrom, sameRoad, type RoadLine } from '../Map/RouteCalculator'
import { isStoredStop, type RouteSegment } from '@trek/shared/roadtrip'
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
import type { OfferedRoute, RailDrive, ViaAnchor } from './useRouteAlternatives'
/** The most points one choice may pin to the road (see `MAX_WAY_PINS`). */
export const MAX_PINS = MAX_WAY_PINS
/**
* The most points one choice may pin to the road.
*
* Every pin is one more request to a host that allows one a second, and one more handle on
* the map somebody has to understand later. A way that three points cannot hold is not
* held by the router at all: a ferry OSRM will not board, or a class the day is not
* weighed against.
*/
export const MAX_PINS = 3
/** A point a choice is held by, in the order the chosen road drives through them. */
export type Pin = WayPin
export interface Pin {
lat: number
lng: number
}
/** Whether the rail's router drives an offer, and the pins that make it do so. */
export type PinProof = PinnedWay<RailLegRoute>
export interface PinProof {
/** True when the router drove the offer with `pins` in place. */
held: boolean
/** The pins that held it, in the order the offer drives through them. Empty otherwise. */
pins: Pin[]
/** True when the last answer came from a stand-in engine, which proves nothing either way. */
fellBack: boolean
/** The router's answer to the last attempt. */
last: RailLegRoute
}
/**
* The fewest points that make the rail's own router drive `offer`, found by asking it.
@@ -19,10 +37,19 @@ export type PinProof = PinnedWay<RailLegRoute>
* A choice used to be stored as one via at the point where the offer strayed furthest,
* and nobody asked whether the router then drove the offer. Often it did not. A point on
* a ferry is pulled to the nearest pier by OSRM, which never boards one, so Amsterdam to
* Newcastle "without the motorway" came back as a thousand kilometres through Calais. So
* the check is the router itself, the way `pinWay` asks it; the spacing between the
* requests is the router's own (`RailLegRouter.route`). The server asks the same way for
* the ways of a hike's leg.
* Newcastle "without the motorway" came back as a thousand kilometres through Calais. An
* offer weighed away from motorways by the second engine is held by one point for a few
* kilometres, and the motorway takes the rest. Either way the choice looked saved and the
* drive did something else.
*
* So the check is the router itself. The router's own preference starts with no pin; any
* other offer with the point furthest from the road being driven. After each answer that
* is not the offer, the point of the offer furthest from that answer joins in, placed in
* the order the offer drives through them, until the answer is the offer or `maxPins` is
* reached. The spacing between the requests is the router's own (`RailLegRouter.route`).
*
* An answer from a stand-in engine proves nothing about the one that will draw the leg,
* so it ends the check without a verdict on the road.
*/
export async function pinAlternative({
offer,
@@ -38,9 +65,23 @@ export async function pinAlternative({
signal: AbortSignal
maxPins?: number
}): Promise<PinProof> {
const proof = await pinWay({ offer, current, route: (pins) => route(pins, signal), signal, maxPins })
if (!proof) throw new DOMException('The check was abandoned', 'AbortError')
return proof
const placed: { index: number; lat: number; lng: number }[] = []
if (!offer.direct) {
const far = furthestFrom(offer.coordinates, current?.coordinates ?? [])
if (far) placed.push(far)
}
for (;;) {
if (signal.aborted) throw new DOMException('The check was abandoned', 'AbortError')
const last = await route(placed.map(({ lat, lng }) => ({ lat, lng })), signal)
if (last.fellBack) return { held: false, pins: [], fellBack: true, last }
if (sameRoad(last, offer)) return { held: true, pins: placed.map(({ lat, lng }) => ({ lat, lng })), fellBack: false, last }
if (placed.length >= maxPins) return { held: false, pins: [], fellBack: false, last }
const far = furthestFrom(offer.coordinates, last.coordinates)
// The same point again would ask the same question and earn the same answer.
if (!far || placed.some(p => p.index === far.index)) return { held: false, pins: [], fellBack: false, last }
placed.push(far)
placed.sort((a, b) => a.index - b.index)
}
}
/**
@@ -1,6 +1,5 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { sameRoad } from '@trek/shared'
import { calculateAlternatives, type RouteAlternative, type RouteEngine } from '../Map/RouteCalculator'
import { calculateAlternatives, sameRoad, type RouteAlternative, type RouteEngine } from '../Map/RouteCalculator'
import type { RailLegRoute, RailLegRouter } from './useRoadtripRoutes'
/** One offered way of driving a leg, with what it means for this trip. */
@@ -91,7 +91,7 @@ function SheetView({
height: `${sheet.height + box.bleed * 2}mm`,
// The spread's own paper colour, carried out into the bleed. Without
// it a coloured page would be cut with a white edge on three sides.
background: (sheet.spread.backgroundPaint ? null : sheet.spread.background) ?? '#ffffff', // theme-lint-disable: paper, not app chrome
background: sheet.spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
overflow: 'hidden',
}}
>
@@ -1,159 +0,0 @@
import { type BookLineElement, MAX_LINE_POINTS, bookLinePoints, bookLineWithPoints } from '@trek/shared';
import { useRef } from 'react';
import { useStudioStore } from '../../store/studioStore';
import { constrainPoint } from './lineSnap';
/**
* The canvas handles of a selected line: one on each of its points, to drag
* it where it should go, and a smaller one half way along each segment that
* adds a point when it is pulled. Double-clicking a point between the ends
* takes it out again.
*
* Drawn inside the selection box, which carries the line's turn, at the
* points' own fractions of it, so they sit on the drawing at any zoom and any
* angle. The drag itself is measured on the spread, where the pointer is, and
* the frame is refitted around the moved points by `bookLineWithPoints`, the
* same refit the book engine uses.
*
* While dragging, a segment within a couple of degrees of level or plumb is
* made exactly level or plumb, and Shift holds it to 15 degree steps.
*/
type Pt = [number, number];
interface Drag {
index: number;
start: Pt;
pointerX: number;
pointerY: number;
points: Pt[];
}
export function LineHandles({
el,
spreadIndex,
scaled,
t,
}: {
el: BookLineElement;
spreadIndex: number;
/** Screen pixels per millimetre at the current zoom. */
scaled: number;
t: (k: string) => string;
}) {
const update = useStudioStore((s) => s.updateElement);
const beginGesture = useStudioStore((s) => s.beginGesture);
const endGesture = useStudioStore((s) => s.endGesture);
const commit = useStudioStore((s) => s.commit);
const drag = useRef<Drag | null>(null);
const place = (points: Pt[]) => {
const next = bookLineWithPoints(el, points);
update(spreadIndex, el.id, { frame: next.frame, points: next.points });
};
const grab = (e: React.PointerEvent, index: number, points: Pt[]) => {
e.stopPropagation();
e.preventDefault();
(e.target as Element).setPointerCapture?.(e.pointerId);
beginGesture();
drag.current = { index, start: points[index], pointerX: e.clientX, pointerY: e.clientY, points };
};
const onMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d) return;
e.stopPropagation();
const raw: Pt = [d.start[0] + (e.clientX - d.pointerX) / scaled, d.start[1] + (e.clientY - d.pointerY) / scaled];
const anchor = d.points[d.index === 0 ? 1 : d.index - 1];
const points = d.points.slice();
points[d.index] = constrainPoint(raw, anchor, e.shiftKey);
place(points);
};
const onUp = (e: React.PointerEvent) => {
if (!drag.current) return;
e.stopPropagation();
drag.current = null;
endGesture();
};
/** Pull a new point out of the middle of segment `i`. */
const insert = (e: React.PointerEvent, i: number) => {
const world = bookLinePoints(el) as Pt[];
const a = world[i];
const b = world[i + 1];
const points = [...world.slice(0, i + 1), [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] as Pt, ...world.slice(i + 1)];
grab(e, i + 1, points);
place(points);
};
const remove = (i: number) => {
const world = bookLinePoints(el) as Pt[];
if (world.length <= 2) return;
const next = bookLineWithPoints(
el,
world.filter((_, k) => k !== i)
);
commit((d) => ({
...d,
spreads: d.spreads.map((sp, k) =>
k !== spreadIndex
? sp
: {
...sp,
elements: sp.elements.map((x) =>
x.kind === 'line' && x.id === el.id ? { ...x, frame: next.frame, points: next.points } : x
),
}
),
}));
};
// A point on the drawing, measured from the frame, not a side of the text.
const pct = (p: { x: number; y: number }) => ({ left: `${p.x * 100}%`, top: `${p.y * 100}%` }); // rtl-lint-disable
const canAdd = el.points.length < MAX_LINE_POINTS;
return (
<>
{canAdd &&
el.points.slice(1).map((p, i) => {
const a = el.points[i];
return (
<span
key={`m${i}`}
className="st-handle is-corner"
style={{
...pct({ x: (a.x + p.x) / 2, y: (a.y + p.y) / 2 }),
cursor: 'copy',
transform: 'scale(.7)',
opacity: 0.7,
}}
title={t('journey.studio.addPoint')}
onPointerDown={(e) => insert(e, i)}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
/>
);
})}
{el.points.map((p, i) => (
<span
key={`p${i}`}
className="st-handle is-corner"
style={{ ...pct(p), cursor: 'crosshair' }}
title={i > 0 && i < el.points.length - 1 ? t('journey.studio.removePoint') : undefined}
onPointerDown={(e) => grab(e, i, bookLinePoints(el) as Pt[])}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onDoubleClick={(e) => {
e.stopPropagation();
if (i > 0 && i < el.points.length - 1) remove(i);
}}
/>
))}
</>
);
}
-46
View File
@@ -1,46 +0,0 @@
import type { BookLineElement } from '@trek/shared';
import type { CSSProperties } from 'react';
import { useMemo } from 'react';
import { type LineGeometry, lineGeometry } from './lineRender';
/** The paths of a line's geometry in one colour: the body stroked, the heads and the ornament filled. */
export function LinePaths({ g, color, width }: { g: LineGeometry; color: string; width: number }) {
const stroke = { fill: 'none', stroke: color, strokeWidth: width, strokeLinejoin: 'round' as const };
return (
<>
{g.body.map((d, i) => (
<path key={`b${i}`} d={d} {...stroke} strokeLinecap={g.cap} strokeDasharray={g.dash} />
))}
{g.strokes.map((d, i) => (
<path key={`s${i}`} d={d} {...stroke} strokeLinecap="round" />
))}
{g.fills.map((d, i) => (
<path key={`f${i}`} d={d} fill={color} />
))}
</>
);
}
/**
* A line element, drawn: its body stroked, its heads and ornament filled, all
* in the millimetres of its own frame (one SVG unit is one millimetre, as for
* the shapes). Overflow stays visible so a head on a thin frame is not cut.
*/
export function LineView({ el, frameStyle }: { el: BookLineElement; frameStyle: CSSProperties }) {
const { w, h } = el.frame;
const g = useMemo(() => lineGeometry(el, w, h, el.id), [el, w, h]);
return (
<div style={frameStyle}>
<svg
width={`${w}mm`}
height={`${h}mm`}
viewBox={`0 0 ${w} ${h}`}
style={{ display: 'block', overflow: 'visible' }}
aria-hidden
>
<LinePaths g={g} color={el.stroke} width={el.strokeWidth} />
</svg>
</div>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { BookPageSetup, BookSpread } from '@trek/shared'
import { fontStack, nearestWeight } from './bookFonts'
import { fontStack } from './bookFonts'
import { folioInk } from './folioColour'
import { isNumbered } from './bookSheets'
@@ -94,7 +94,7 @@ export function PageNumbers({
textAlign: at.align,
fontFamily: fontStack(cfg.font),
fontSize: `${size}pt`,
fontWeight: nearestWeight(cfg.font, 500),
fontWeight: 500,
letterSpacing: '0.08em',
lineHeight: 1,
color: ink.color,
-433
View File
@@ -1,433 +0,0 @@
import { photoLookOf, type BookFrame, type BookPhotoElement, type BookShapeId, type PhotoLook } from '@trek/shared';
import type { CSSProperties, ReactNode } from 'react';
import { fontStack } from './bookFonts';
import { photoSrc } from './bookRender';
import { usePaintId } from './paint/usePaintId';
import { pictureFilter, silhouetteShadows, svgFilterMatrices, zoomPlacement } from './photo/photoLook';
import { photoClip } from './shapeOutline';
import { HOLED_SHAPES, SHAPE_PATHS, scalePath } from './shapes';
/**
* A picture on the page: possibly cut to a shape, possibly sitting in
* something, possibly wearing a look (book-photo-look.schema.ts in shared).
*
* Split out of SpreadView.tsx, which draws every kind and had no room left
* for the look. It is still the one renderer: the editor, the page
* thumbnails and the print path all draw a photo through this.
*
* 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.
*
* ── The look, and how each part prints ───────────────────────────────────
*
* Border, hard shadow and cutout are flat shapes of one colour, so they stay
* vector in the PDF. On a rectangular frame (no mask, or a card such as a
* Polaroid around it) the cutout and the shadow are box shadows without blur,
* which follow the corner radius; on a frame cut to a shape they are that
* shape's path, drawn behind the picture. The border is drawn over the edge of
* the picture, inside the frame, so the frame stays the box the fold and trim
* checks measure. Zoom and mirror move the picture inside its clip and change
* nothing around it. Adjustments, warmth and a duotone are filters and print
* as a raster, which the inspector says.
*
* Every SVG id is per instance (usePaintId): the page view draws each spread twice,
* and two clip paths with one id cut the second picture with the first one's
* shape.
*/
/** Two decimals of a millimetre, matching what the document itself stores. */
const round2 = (n: number) => Math.round(n * 100) / 100;
const PHOTO_FILTERS: Record<string, string | undefined> = {
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<string, { pad: number; bottom: number }> = {
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 },
};
/** The frame styles that draw a card around the picture, which makes the frame's outline a rectangle. */
const CARDS = new Set(['polaroid', 'white', 'film']);
/** The cutout and the hard shadow of a frame cut to a shape: its path, grown and offset, behind the picture. */
function ShapeSilhouette({ frame, mask, look }: { frame: BookFrame; mask: BookShapeId; look: PhotoLook }) {
const { w, h } = frame;
const d = scalePath(SHAPE_PATHS[mask], w, h);
const rule = HOLED_SHAPES.has(mask) ? 'evenodd' : undefined;
const grow = look.cutout?.width ?? 0;
const path = (color: string, dx = 0, dy = 0) => (
<path
d={d}
transform={dx || dy ? `translate(${dx} ${dy})` : undefined}
fill={color}
fillRule={rule}
stroke={grow ? color : undefined}
strokeWidth={grow ? grow * 2 : undefined}
strokeLinejoin="round"
/>
);
return (
<svg
aria-hidden
width={`${w}mm`}
height={`${h}mm`}
viewBox={`0 0 ${w} ${h}`}
style={{ position: 'absolute', inset: 0, overflow: 'visible', display: 'block' }}
>
{look.shadow && <g opacity={look.shadow.alpha}>{path(look.shadow.color, look.shadow.dx, look.shadow.dy)}</g>}
{look.cutout && path(look.cutout.color)}
</svg>
);
}
/** The border: a band along the inside of the picture's edge, following its shape. */
function BorderBand({
mask,
radius,
look,
width,
height,
}: {
mask: BookShapeId | null;
radius: number;
look: PhotoLook;
width: number;
height: number;
}) {
const border = look.border;
if (!border || width <= 0 || height <= 0) return null;
if (mask && mask !== 'rect') {
// Stroked twice as wide as the band and cut by the picture's own clip, so
// exactly the inner half shows and the band follows every curve.
return (
<svg
aria-hidden
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
>
<path
d={scalePath(SHAPE_PATHS[mask], width, height)}
fill="none"
stroke={border.color}
strokeWidth={border.width * 2}
strokeLinejoin="round"
/>
</svg>
);
}
return (
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
borderStyle: 'solid',
borderWidth: `${border.width}mm`,
borderColor: border.color,
borderRadius: radius ? `${radius}mm` : undefined,
boxSizing: 'border-box',
}}
/>
);
}
/** The picture itself, zoomed about its focal point and mirrored where the look says so. */
function Picture({
el,
photoId,
look,
big,
print,
filterId,
}: {
el: BookPhotoElement;
photoId: number;
look: PhotoLook;
big: boolean;
print: boolean;
filterId: string | null;
}) {
const zoom = zoomPlacement(look.zoom, el.focalX, el.focalY);
const img = (
<img
src={photoSrc(photoId, big)}
alt=""
draggable={false}
// Lazy on the canvas, where a long book scrolls past; eager in print,
// where every sheet is laid out at once and a picture the browser put
// off is a picture missing from the PDF.
loading={print ? 'eager' : 'lazy'}
style={{
width: zoom?.size ?? '100%',
height: zoom?.size ?? '100%',
transform: zoom?.transform,
objectFit: el.fit,
objectPosition: `${el.focalX * 100}% ${el.focalY * 100}%`,
filter: pictureFilter(PHOTO_FILTERS[el.filter], look, filterId),
display: 'block',
}}
/>
);
if (!look.flipX) return img;
// The picture is mirrored inside a box the size of the frame, so the part
// the crop shows stays the same part and the mask is not mirrored with it.
return <div style={{ position: 'absolute', inset: 0, transform: 'scaleX(-1)' }}>{img}</div>;
}
/** What an empty frame shows while editing: a hatch, and where there is room, the instruction. */
function Hatch({ clipped, label, labelSize }: { clipped: boolean; label: ReactNode; labelSize: number }) {
return (
<div
style={{
position: 'absolute',
inset: 0,
/*
* Opaque, and that is the point.
*
* The hatch used to be laid over nothing, so an empty frame showed
* whatever was behind it and every attempt to arrange a shape *under*
* one looked like it had done nothing: the frame appeared to sit at the
* bottom of the stack no matter where it was, right up until a
* photograph landed in it and it suddenly covered things. A frame is a
* promise that a picture goes here, and a picture is not see-through.
*/
background: 'repeating-linear-gradient(45deg, rgba(0,0,0,.055) 0 6px, rgba(0,0,0,.025) 6px 12px), #ffffff', // theme-lint-disable: paper, not app chrome
// A dashed border would be cut in half lengthwise by the clip, so a
// masked placeholder shows its shape through the hatch alone.
border: clipped ? undefined : '1px dashed rgba(0,0,0,.16)',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2mm',
}}
>
{label && (
<span
style={{
fontFamily: fontStack('sans'),
fontSize: `${labelSize}mm`,
fontWeight: 600,
letterSpacing: '0.16em',
lineHeight: 1.5,
textAlign: 'center',
textTransform: 'uppercase',
color: 'rgba(0,0,0,.34)',
whiteSpace: 'pre-line',
userSelect: 'none',
}}
>
{label}
</span>
)}
</div>
);
}
export function PhotoView({
el,
big,
print,
dropLabel,
frameStyle,
}: {
el: BookPhotoElement;
big: boolean;
print: boolean;
dropLabel: string;
/** The positioned box every element sits in, from SpreadView. */
frameStyle: CSSProperties;
}) {
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 = usePaintId(`c-${el.id}`);
const filterRef = usePaintId(`f-${el.id}`);
// The shape the picture is cut to; a rect mask is no mask at all.
const mask = el.mask && el.mask !== 'rect' ? el.mask : null;
// The cut as a unit path: the mask, its cut edge, or a cut edge on the rectangle (shapeOutline.ts).
const clip = photoClip(el, el.frame.w - pad * 2, el.frame.h - pad - (bottom || pad));
const clipped = clip !== null;
// An empty frame wears the frame style but none of the look: a border or a
// shadow around a hatch would read as part of the picture to come.
const empty = el.photoId == null;
const look = photoLookOf(empty ? {} : el);
const matrices = svgFilterMatrices(look);
const filterId = matrices.length ? filterRef : null;
const card = CARDS.has(el.frameStyle);
/*
* 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.
*/
// 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;
// A photograph lying on a page, rather than printed into it. Soft and low: a
// hard shadow reads as a UI card, not as paper. The look's own cutout and
// hard shadow join it when the frame's outline is a rectangle.
const soft = el.frameStyle === 'shadow' || el.frameStyle === 'polaroid' ? '0 1.2mm 3mm rgba(0,0,0,.22)' : null;
const boxShadows = [
...(clipped && !card ? [] : silhouetteShadows(look.shadow, look.cutout)),
...(soft ? [soft] : []),
];
const picture = (
<div
style={{
position: 'absolute',
left: `${pad}mm`,
top: `${pad}mm`,
right: `${pad}mm`,
bottom: `${bottom || pad}mm`,
overflow: 'hidden',
borderRadius: !clipped && el.radius ? `${el.radius}mm` : undefined,
clipPath: clipped ? `url(#${clipId})` : undefined,
}}
>
{el.photoId == null ? (
<Hatch clipped={clipped} label={roomy && dropLabel} labelSize={labelSize} />
) : (
<Picture el={el} photoId={el.photoId} look={look} big={big} print={print} filterId={filterId} />
)}
{!empty && (
<BorderBand
mask={el.mask}
radius={el.radius}
look={look}
width={el.frame.w - pad * 2}
height={el.frame.h - pad - (bottom || pad)}
/>
)}
</div>
);
return (
<div
style={{
...frameStyle,
background:
el.frameStyle === 'polaroid' || el.frameStyle === 'white'
? '#ffffff'
: el.frameStyle === 'film'
? '#141414'
: undefined,
borderRadius: !card && !clipped && el.radius ? `${el.radius}mm` : undefined,
boxShadow: boxShadows.length ? boxShadows.join(', ') : undefined,
}}
>
{mask && !card && !empty && (look.shadow || look.cutout) && (
<ShapeSilhouette frame={el.frame} mask={mask} look={look} />
)}
{(clip || filterId) && (
<svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden>
<defs>
{clip && (
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
<path d={clip} />
</clipPath>
)}
{filterId && (
<filter id={filterId} colorInterpolationFilters="sRGB">
{matrices.map((values, i) => (
<feColorMatrix key={i} type="matrix" values={values} />
))}
</filter>
)}
</defs>
</svg>
)}
{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) => (
<div
key={row}
style={{
position: 'absolute',
left: `${pad * 0.35}mm`,
right: `${pad * 0.35}mm`,
[row ? 'bottom' : 'top']: `${pad * 0.22}mm`,
height: `${pad * 0.42}mm`,
background:
'repeating-linear-gradient(90deg,' +
` #f4f4f4 0 ${pad * 0.5}mm, transparent ${pad * 0.5}mm ${pad * 1.1}mm)`,
}}
/>
))}
{/*
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 (
<div
key={dir}
style={{
position: 'absolute',
[dir < 0 ? 'left' : 'right']: `${round2(-tapeW / 2)}mm`,
top: `${round2(-tapeH / 2)}mm`,
width: `${round2(tapeW)}mm`,
height: `${round2(tapeH)}mm`,
background: 'rgba(236,228,206,.82)',
boxShadow: '0 .3mm .6mm rgba(0,0,0,.12)',
// Away from the corner on each side: anticlockwise on the left,
// clockwise on the right, so the pair reads as symmetric.
transform: `rotate(${dir * 45}deg)`,
}}
/>
);
})}
</div>
);
}
+243 -35
View File
@@ -1,21 +1,13 @@
import type { CSSProperties } from 'react'
import type {
BookElement, BookIconElement, BookPageSetup, BookShapeElement, BookSpread,
BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
} from '@trek/shared'
import { fontStack, nearestWeight } from './bookFonts'
import { photoSrc } from './bookRender'
import { fontStack } from './bookFonts'
import { iconComponent } from './iconLibrary'
import { PhotoView } from './PhotoView'
import { HOLED_SHAPES } from './shapes'
import { hasEdge, shapeOutline } from './shapeOutline'
import { LineView } from './LineView'
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
import { TravelElementView } from './TravelElements'
import { TextEffectsView } from './TextEffectsView'
import { hasTextEffects } from './textEffects'
import { PageNumbers } from './PageNumbers'
import { PaintedShapeView } from './paint/PaintedShape'
import { SpreadPaint } from './paint/paintRender'
import { usePaintId } from './paint/usePaintId'
import { StickerView } from './StickerView'
/**
* One spread, drawn.
@@ -42,12 +34,42 @@ function frameStyle(el: BookElement): CSSProperties {
}
}
/** 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 = Number.parseInt(hex.slice(1), 16)
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
}
const FILTERS: Record<string, string | undefined> = {
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<string, { pad: number; bottom: number }> = {
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.
*
@@ -64,11 +86,10 @@ function hexToRgba(hex: string, alpha: number): string {
*/
function ShapeView({ el }: { el: BookShapeElement }) {
const fill = el.fill ?? 'transparent'
// Per mounted copy: page mode draws a spread twice (paint/paintRender.tsx).
const gradientId = usePaintId(`g-${el.id}`)
const gradientId = `g-${el.id}`
const gradient = el.gradient !== 'none' && el.fill
if ((el.shape === 'rect' || el.shape === 'ellipse') && !hasEdge(el, el.shape)) {
if (el.shape === 'rect' || el.shape === 'ellipse') {
const background = !gradient
? fill
: `linear-gradient(${el.gradient === 'up' ? 'to top' : 'to bottom'},`
@@ -91,7 +112,7 @@ function ShapeView({ el }: { el: BookShapeElement }) {
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 = shapeOutline(el, w, h)
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.
@@ -132,6 +153,206 @@ function ShapeView({ el }: { el: BookShapeElement }) {
)
}
/**
* 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 = (
<div
style={{
position: 'absolute',
inset: 0,
/*
* Opaque, and that is the point.
*
* The hatch used to be laid over nothing, so an empty frame showed
* whatever was behind it and every attempt to arrange a shape *under*
* one looked like it had done nothing: the frame appeared to sit at the
* bottom of the stack no matter where it was, right up until a
* photograph landed in it and it suddenly covered things. A frame is a
* promise that a picture goes here, and a picture is not see-through.
*/
background:
'repeating-linear-gradient(45deg, rgba(0,0,0,.055) 0 6px, rgba(0,0,0,.025) 6px 12px), #ffffff', // theme-lint-disable — paper, not app chrome
// A dashed border would be cut in half lengthwise by the clip, so a
// masked placeholder shows its shape through the hatch alone.
border: clipped ? undefined : '1px dashed rgba(0,0,0,.16)',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2mm',
}}
>
{roomy && dropLabel && (
<span
style={{
fontFamily: fontStack('sans'),
fontSize: `${labelSize}mm`,
fontWeight: 600,
letterSpacing: '0.16em',
lineHeight: 1.5,
textAlign: 'center',
textTransform: 'uppercase',
color: 'rgba(0,0,0,.34)',
whiteSpace: 'pre-line',
userSelect: 'none',
}}
>
{dropLabel}
</span>
)}
</div>
)
const picture = (
<div
style={{
position: 'absolute',
left: `${pad}mm`,
top: `${pad}mm`,
right: `${pad}mm`,
bottom: `${bottom || pad}mm`,
overflow: 'hidden',
borderRadius: !clipped && el.radius ? `${el.radius}mm` : undefined,
clipPath: clipped ? `url(#${clipId})` : undefined,
}}
>
{empty ? hatch : (
<img
src={photoSrc(el.photoId!, big)}
alt=""
draggable={false}
// Lazy on the canvas, where a long book scrolls past; eager in print,
// where every sheet is laid out at once and a picture the browser put
// off is a picture missing from the PDF.
loading={print ? 'eager' : 'lazy'}
style={{
width: '100%',
height: '100%',
objectFit: el.fit,
objectPosition: `${el.focalX * 100}% ${el.focalY * 100}%`,
filter: FILTERS[el.filter],
display: 'block',
}}
/>
)}
</div>
)
return (
<div
style={{
...frameStyle(el),
background: el.frameStyle === 'polaroid' || el.frameStyle === 'white' ? '#ffffff'
: el.frameStyle === 'film' ? '#141414'
: undefined,
// A photograph lying on a page, rather than printed into it. Soft and
// low: a hard shadow reads as a UI card, not as paper.
boxShadow: el.frameStyle === 'shadow' || el.frameStyle === 'polaroid'
? '0 1.2mm 3mm rgba(0,0,0,.22)'
: undefined,
}}
>
{clipped && (
<svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden>
<defs>
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
<path d={unitPath(SHAPE_PATHS[el.mask!])} />
</clipPath>
</defs>
</svg>
)}
{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 => (
<div
key={row}
style={{
position: 'absolute',
left: `${pad * 0.35}mm`,
right: `${pad * 0.35}mm`,
[row ? 'bottom' : 'top']: `${pad * 0.22}mm`,
height: `${pad * 0.42}mm`,
background: 'repeating-linear-gradient(90deg,'
+ ` #f4f4f4 0 ${pad * 0.5}mm, transparent ${pad * 0.5}mm ${pad * 1.1}mm)`,
}}
/>
))}
{/* 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 (
<div
key={dir}
style={{
position: 'absolute',
[dir < 0 ? 'left' : 'right']: `${round2(-tapeW / 2)}mm`,
top: `${round2(-tapeH / 2)}mm`,
width: `${round2(tapeW)}mm`,
height: `${round2(tapeH)}mm`,
background: 'rgba(236,228,206,.82)',
boxShadow: '0 .3mm .6mm rgba(0,0,0,.12)',
// Away from the corner on each side: anticlockwise on the left,
// clockwise on the right, so the pair reads as symmetric.
transform: `rotate(${dir * 45}deg)`,
}}
/>
)
})}
</div>
)
}
/**
* A lucide drawing, at the size the frame gives it.
*
@@ -168,20 +389,16 @@ export function ElementView({
// A photo left out is drawn as its empty frame, kept even in print, so the
// page still shows where a picture sits without showing the picture.
if (el.photoId != null && hiddenPhotoIds?.has(el.photoId)) {
return <PhotoView el={{ ...el, photoId: null }} big={big} print={false} dropLabel="" frameStyle={frameStyle(el)} />
return <PhotoView el={{ ...el, photoId: null }} big={big} print={false} dropLabel="" />
}
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} frameStyle={frameStyle(el)} />
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} />
}
if (el.kind === 'shape') {
return el.paint ? <PaintedShapeView el={el} paint={el.paint} frameStyle={frameStyle(el)} /> : <ShapeView el={el} />
}
if (el.kind === 'shape') return <ShapeView el={el} />
// Above the catch-all below, which routes everything that is not text into
// the travel elements and draws null for anything they do not know.
if (el.kind === 'icon') return <IconView el={el} />
if (el.kind === 'sticker') return <StickerView el={el} frameStyle={frameStyle(el)} />
if (el.kind === 'line') return <LineView el={el} frameStyle={frameStyle(el)} />
if (el.kind !== 'text') {
// map, stats, countries, badge — the elements drawn from the journey's own
@@ -191,9 +408,6 @@ export function ElementView({
return <TravelElementView el={el} frameStyle={frameStyle(el)} big={big} />
}
// Outlines, shadows, bands, curves and fills draw in their own file; a plain text stays here, as it always drew.
if (hasTextEffects(el)) return <TextEffectsView el={el} frameStyle={frameStyle(el)} big={big} />
return (
<div
style={{
@@ -203,7 +417,7 @@ export function ElementView({
// the conversion exactly.
fontSize: `${el.size}pt`,
fontFamily: fontStack(el.font),
fontWeight: nearestWeight(el.font, el.weight),
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
@@ -260,8 +474,7 @@ export function SpreadView({
style={{
position: 'absolute',
inset: 0,
// Under a paint the sheet is the paper, which a see-through paint lets show.
background: (spread.backgroundPaint ? null : spread.background) ?? '#ffffff', // theme-lint-disable: paper, not app chrome
background: spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
/*
* On screen the page ends at the trim, so anything past it is clipped.
* In print it must not be: the bleed is the part that runs off the edge
@@ -272,12 +485,7 @@ export function SpreadView({
overflow: print ? 'visible' : 'hidden',
}}
>
{spread.backgroundPaint && (
<SpreadPaint paint={spread.backgroundPaint} width={w} height={page.pageHeight} bleed={page.bleed} idPrefix={spread.id} />
)}
{/* A hidden layer stays in the layers panel and off the page, here and in print. */}
{spread.elements.filter(el => !el.hidden).map(el => (
{spread.elements.map(el => (
<ElementView
key={el.id}
el={el}
@@ -1,72 +0,0 @@
import { bookStickerLayerPaint, type BookStickerElement } from '@trek/shared';
import { bookSticker, type BookStickerDef } from '@trek/shared/stickers';
import type { CSSProperties } from 'react';
/**
* A sticker, drawn: its layers as one inline SVG in the sticker's own box,
* scaled to the frame by the viewBox, so every layer stays a vector path in
* the print and in the page image the assistant reads.
*
* The drawing keeps its proportions inside whatever frame it is given (meet),
* the way an icon does: a lemon stretched to twice its width stops being a
* lemon. A flip mirrors the drawing inside the frame, so the frame, its
* handles and its rotation stay where they were.
*
* A sticker this build's library does not hold draws nothing. Only a newer
* book can carry one, and a book that is newer than this reader is opened
* read-only (book-schema-rev.ts), so nothing is lost by not drawing it.
*/
/** The drawing alone, for the page, the panel's tiles and the inspector. */
export function StickerArt({
def,
colors = [],
flipX = false,
}: {
def: BookStickerDef;
colors?: readonly string[];
flipX?: boolean;
}) {
const [w, h] = def.box;
return (
<svg
width="100%"
height="100%"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="xMidYMid meet"
aria-hidden
style={{ display: 'block', overflow: 'visible', transform: flipX ? 'scaleX(-1)' : undefined }}
>
{def.layers.map((layer, i) => {
const { color, opacity } = bookStickerLayerPaint(layer, def.slots, colors);
const alpha = opacity < 1 ? opacity : undefined;
return layer.w ? (
<path
// A sticker's layers never reorder: their place in the list is their identity.
key={i}
d={layer.d}
fill="none"
stroke={color}
strokeOpacity={alpha}
strokeWidth={layer.w}
strokeLinecap="round"
strokeLinejoin="round"
/>
) : (
<path key={i} d={layer.d} fill={color} fillOpacity={alpha} fillRule={layer.eo ? 'evenodd' : undefined} />
);
})}
</svg>
);
}
/** A sticker element on the page, in its frame. */
export function StickerView({ el, frameStyle }: { el: BookStickerElement; frameStyle: CSSProperties }) {
const def = bookSticker(el.stickerId);
if (!def) return null;
return (
<div style={frameStyle}>
<StickerArt def={def} colors={el.colors} flipX={el.flipX} />
</div>
);
}
@@ -1,172 +0,0 @@
import type { BookAlignEdge, BookAlignTo, BookSpread } from '@trek/shared';
import { distributableCount, isWholeGroup } from '@trek/shared';
import {
AlignCenterHorizontal,
AlignCenterVertical,
AlignEndHorizontal,
AlignEndVertical,
AlignHorizontalDistributeCenter,
AlignStartHorizontal,
AlignStartVertical,
AlignVerticalDistributeCenter,
Group,
Paintbrush,
Pipette,
Ungroup,
type LucideIcon,
} from 'lucide-react';
import { useState } from 'react';
import { useStudioStore } from '../../store/studioStore';
import { Tooltip } from '../shared/Tooltip';
import { Choice, Section } from './StudioControls';
import { useStudioShortcut } from './studioShortcut';
/**
* Align, distribute, group and the style clipboard, for whatever is selected.
*
* The align bar lines a multi-selection up within its own box by default, and
* a single element (or one whole group) against its page, since there is
* nothing else for it to line up with. Groups move as one box throughout
* (shared/src/book/book-arrange.ts). Every press is one undo step.
*/
const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
{ edge: 'left', icon: AlignStartVertical, labelKey: 'journey.studio.layers.alignLeft' },
{ edge: 'center', icon: AlignCenterVertical, labelKey: 'journey.studio.layers.alignCenter' },
{ edge: 'right', icon: AlignEndVertical, labelKey: 'journey.studio.layers.alignRight' },
{ edge: 'top', icon: AlignStartHorizontal, labelKey: 'journey.studio.layers.alignTop' },
{ edge: 'middle', icon: AlignCenterHorizontal, labelKey: 'journey.studio.layers.alignMiddle' },
{ edge: 'bottom', icon: AlignEndHorizontal, labelKey: 'journey.studio.layers.alignBottom' },
];
/** How many things move as one in a selection: a group counts once. */
function unitCount(spread: BookSpread, ids: string[]): number {
return distributableCount(
spread,
spread.elements.filter((e) => ids.includes(e.id))
);
}
function IconButton({
label,
icon: Icon,
onClick,
disabled,
shortcut,
}: {
label: string;
icon: LucideIcon;
onClick: () => void;
disabled?: boolean;
shortcut?: readonly string[];
}) {
return (
<Tooltip label={label} shortcut={shortcut}>
<button type="button" className="st-chip is-icon" onClick={onClick} disabled={disabled} aria-label={label}>
<Icon size={14} />
</button>
</Tooltip>
);
}
export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number; t: (k: string) => string }) {
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
const selection = useStudioStore((s) => s.selection);
const hasStyle = useStudioStore((s) => s.styleClip !== null);
const align = useStudioStore((s) => s.align);
const distribute = useStudioStore((s) => s.distribute);
const group = useStudioStore((s) => s.group);
const ungroup = useStudioStore((s) => s.ungroup);
const copyStyle = useStudioStore((s) => s.copyStyle);
const pasteStyle = useStudioStore((s) => s.pasteStyle);
const keys = useStudioShortcut();
const [chosenTo, setChosenTo] = useState<BookAlignTo | null>(null);
if (!spread || !selection.length) return null;
const sel = spread.elements.filter((e) => selection.includes(e.id));
const movable = sel.filter((e) => !e.locked).map((e) => e.id);
const units = unitCount(spread, movable);
// One thing has nothing to line up with but its page.
const to: BookAlignTo = units < 2 ? (chosenTo === 'safe' ? 'safe' : 'page') : (chosenTo ?? 'selection');
const grouped = sel.some((e) => e.group);
const canGroup = movable.length >= 2 && !isWholeGroup(spread, movable);
const asGroup = selection.length > 1 && isWholeGroup(spread, selection);
return (
// Open for a multi-selection, where lining up is what comes next; folded for one element.
<Section key={units > 1 ? 'many' : 'one'} label={t('journey.studio.layers.align')} defaultOpen={units > 1}>
<div className="st-row">
{ALIGN.map((a) => (
<IconButton
key={a.edge}
label={t(a.labelKey)}
icon={a.icon}
disabled={!movable.length}
onClick={() => align(spreadIndex, a.edge, to)}
/>
))}
<span className="st-row-sep" aria-hidden />
<IconButton
label={t('journey.studio.layers.distributeH')}
icon={AlignHorizontalDistributeCenter}
disabled={units < 3}
onClick={() => distribute(spreadIndex, 'horizontal')}
/>
<IconButton
label={t('journey.studio.layers.distributeV')}
icon={AlignVerticalDistributeCenter}
disabled={units < 3}
onClick={() => distribute(spreadIndex, 'vertical')}
/>
</div>
<div className="st-arrange-to">
<span className="st-line-label">{t('journey.studio.layers.alignTo')}</span>
<Choice<BookAlignTo>
value={to}
ariaLabel={t('journey.studio.layers.alignTo')}
options={[
{ value: 'selection' as const, label: t('journey.studio.layers.toSelection'), disabled: units < 2 },
{ value: 'page' as const, label: t('journey.studio.layers.toPage') },
{ value: 'safe' as const, label: t('journey.studio.layers.toSafe') },
]}
onPick={setChosenTo}
/>
</div>
<div className="st-row st-arrange-acts">
{(canGroup || !grouped) && (
<IconButton
label={t('journey.studio.layers.groupAction')}
icon={Group}
disabled={!canGroup}
shortcut={keys('G')}
onClick={() => group(spreadIndex)}
/>
)}
{grouped && (
<IconButton
label={t('journey.studio.layers.ungroup')}
icon={Ungroup}
shortcut={keys('⇧', 'G')}
onClick={() => ungroup(spreadIndex)}
/>
)}
<span className="st-row-sep" aria-hidden />
<IconButton
label={t('journey.studio.layers.copyStyle')}
icon={Pipette}
shortcut={keys('alt', 'C')}
onClick={() => copyStyle(spreadIndex)}
/>
<IconButton
label={t('journey.studio.layers.pasteStyle')}
icon={Paintbrush}
disabled={!hasStyle || !movable.length}
shortcut={keys('alt', 'V')}
onClick={() => pasteStyle(spreadIndex)}
/>
</div>
{asGroup && <p className="st-hint st-arrange-hint">{t('journey.studio.layers.groupHint')}</p>}
</Section>
);
}
@@ -1,130 +0,0 @@
import { bookElementId, spreadPaint, withSpreadPaint, type BookPageSetup } from '@trek/shared';
import { Check, CopyCheck, ImageOff } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useStudioStore } from '../../store/studioStore';
import { photoSrc } from './bookRender';
import {
backgroundCandidates,
backgroundPhotoOf,
backgroundToAll,
withBackgroundPhoto,
withoutBackgroundPhoto,
} from './paint/backgroundPhoto';
import { spreadPhotoIds } from './paint/paletteModel';
import { usePaintChannel } from './paint/usePaintChannel';
import { Section } from './StudioControls';
import { StudioPaintPicker } from './StudioPaintPicker';
import type { JourneySource, StudioT } from './StudioSidebar';
/**
* The page's background, in the inspector when nothing is selected.
*
* Until now only the assistant could set it; a person had to lay a shape
* across the page and remember to send it to the back. Three things, in the
* order people reach for them: the paint itself, the same background for
* every page, and a photograph under the whole page.
*/
export function StudioBackgroundSection({
spreadIndex,
page,
source,
t,
}: {
spreadIndex: number;
page: BookPageSetup;
source: JourneySource;
t: StudioT;
}) {
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
const commit = useStudioStore((s) => s.commit);
const channel = usePaintChannel((paint) => (doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaint(sp, paint) : sp)),
}));
// "Applied" stands in the button for a moment, so a press that changed pages out of sight is seen to have worked.
const [applied, setApplied] = useState(false);
useEffect(() => {
if (!applied) return;
const timer = window.setTimeout(() => setApplied(false), 1600);
return () => window.clearTimeout(timer);
}, [applied]);
const current = backgroundPhotoOf(spread, page);
const candidates = useMemo(() => {
if (!spread) return [];
const entry = source.entries.find((e) => e.id === spread.entryId);
return backgroundCandidates(
spreadPhotoIds(spread),
entry?.photoIds ?? [],
source.photos.map((p) => p.photoId)
);
}, [spread, source]);
if (!spread) return null;
const setPhoto = (photoId: number) =>
commit((doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => {
if (i !== spreadIndex) return sp;
return withBackgroundPhoto(sp, page, photoId, () => bookElementId('p')) ?? sp;
}),
}));
const clearPhoto = () =>
commit((doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withoutBackgroundPhoto(sp, page) : sp)),
}));
const applyToAll = () => {
commit((doc) => backgroundToAll(doc, spreadIndex));
setApplied(true);
};
return (
<Section label={t('journey.studio.paint.background')}>
<StudioPaintPicker
value={spreadPaint(spread)}
channel={channel}
t={t}
noneLabel={t('journey.studio.paint.paper')}
/>
<button type="button" className={`st-chip st-paint-apply ${applied ? 'is-done' : ''}`} onClick={applyToAll}>
{applied ? <Check size={13} strokeWidth={2.4} /> : <CopyCheck size={13} />}
{applied ? t('journey.studio.paint.appliedAll') : t('journey.studio.paint.applyAll')}
</button>
<div className="st-paint-field">
<span className="st-paint-caption">{t('journey.studio.paint.photoBackground')}</span>
{candidates.length ? (
<>
<div className="st-bgphoto-grid">
{candidates.map((id) => (
<button
type="button"
key={id}
className={`st-bgphoto ${current?.photoId === id ? 'is-on' : ''}`}
aria-pressed={current?.photoId === id}
aria-label={t('journey.studio.paint.photoBackground')}
onClick={() => (current?.photoId === id ? clearPhoto() : setPhoto(id))}
>
<img src={photoSrc(id, false)} alt="" loading="lazy" draggable={false} />
</button>
))}
</div>
<p className="st-hint st-paint-hint">{t('journey.studio.paint.photoBackgroundHint')}</p>
{current && (
<button type="button" className="st-chip is-danger st-paint-apply" onClick={clearPhoto}>
<ImageOff size={13} />
{t('journey.studio.paint.removePhotoBackground')}
</button>
)}
</>
) : (
<p className="st-hint st-paint-hint">{t('journey.studio.paint.noPhotos')}</p>
)}
</div>
</Section>
);
}
@@ -1,281 +0,0 @@
import type { BookDocument, BookElement } from '@trek/shared';
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, render } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { StudioCanvas } from './StudioCanvas';
/**
* The sheet with groups and multi-selections (journey-studio WP6): a click
* picks a group, a double-click steps into it, a marquee picks what it
* touches, one box resizes a multi-selection, and the keyboard nudges,
* groups, duplicates and steps back out.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
const PX = 96 / 25.4;
/** The value, or a failed test when it is missing. */
function must<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error('expected a value');
return value;
}
const shape = (id: string, x: number, y: number, over: Partial<BookElement> = {}): BookElement =>
bookShapeElementSchema.parse({
id,
kind: 'shape',
frame: { x, y, w: 20, h: 20 },
shape: 'rect',
...over,
}) as BookElement;
function load(elements: BookElement[]) {
useStudioStore.getState().load({
version: 1,
title: 'T',
page,
spreads: [{ id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null }],
} as BookDocument);
}
function draw() {
const spread = must(useStudioStore.getState().doc).spreads[0] ?? null;
const view = render(
<StudioCanvas spread={spread} spreadIndex={0} page={page} zoom={1} pxPerMm={PX} bookView={false} dropLabel="" />
);
const stage = view.container.querySelector('.st-stage') as HTMLElement;
const sheet = view.container.querySelector('.st-sheet') as HTMLElement;
// The hit targets are the last children of the sheet, one per element shown, in paint order.
const targets = () => Array.from(sheet.children).slice(-elements().filter((e) => !e.hidden).length) as HTMLElement[];
return { ...view, stage, sheet, targets };
}
const store = () => useStudioStore.getState();
const elements = () => must(must(store().doc).spreads[0]).elements;
const el = (id: string) => must(elements().find((e) => e.id === id));
const grouped = () => [shape('a', 10, 10), shape('g1', 60, 10, { group: 'g' }), shape('g2', 90, 40, { group: 'g' })];
beforeEach(() => {
load([]);
useStudioStore.setState({ enteredGroup: null, styleClip: null });
});
describe('groups on the sheet', () => {
it('a click on a member picks the whole group, drawn as one dashed box with handles', () => {
load(grouped());
const { container, stage, targets } = draw();
fireEvent.pointerDown(must(targets()[1]));
fireEvent.pointerUp(stage);
expect(store().selection).toEqual(['g1', 'g2']);
const box = container.querySelector('.st-select-box') as HTMLElement;
expect(box.className).toContain('is-group');
expect(box.querySelectorAll('.st-handle')).toHaveLength(8);
expect(container.querySelectorAll('.st-select.is-member')).toHaveLength(2);
});
it('a double-click steps into the group and picks the member alone', () => {
load(grouped());
const { container, stage, targets } = draw();
fireEvent.pointerDown(must(targets()[2]));
fireEvent.pointerUp(stage);
fireEvent.doubleClick(must(targets()[2]));
expect(store().selection).toEqual(['g2']);
expect(store().enteredGroup).toBe('g');
expect(container.querySelector('.st-select.is-inside')).not.toBeNull();
});
it('a second double-click on a text inside its group edits it', () => {
const words = bookTextElementSchema.parse({
id: 't',
kind: 'text',
frame: { x: 10, y: 10, w: 60, h: 20 },
text: 'Hi',
group: 'g',
}) as BookElement;
load([words, shape('s', 100, 100, { group: 'g' })]);
const { container, targets } = draw();
fireEvent.doubleClick(must(targets()[0]));
expect(container.querySelector('.st-inline-edit')).toBeNull();
fireEvent.doubleClick(must(targets()[0]));
expect(container.querySelector('.st-inline-edit')).not.toBeNull();
});
it('draws no target for a hidden element', () => {
load([shape('a', 10, 10), shape('h', 50, 50, { hidden: true })]);
const { sheet } = draw();
const hits = Array.from(sheet.children).filter((c) => (c as HTMLElement).style.cursor === 'move');
expect(hits).toHaveLength(1);
});
});
describe('pressing on a selection', () => {
it('drags all of a multi-selection, and a click without a drag narrows it to the one pressed', () => {
load([shape('a', 10, 10), shape('b', 60, 10)]);
store().select(['a', 'b']);
const { stage, targets } = draw();
fireEvent.pointerDown(must(targets()[0]), { clientX: 0, clientY: 0 });
fireEvent.pointerMove(stage, { clientX: 10 * PX, clientY: 0 });
fireEvent.pointerUp(stage);
expect(store().selection).toEqual(['a', 'b']);
expect(el('a').frame.x).toBeCloseTo(20);
expect(el('b').frame.x).toBeCloseTo(70);
fireEvent.pointerDown(must(targets()[1]), { clientX: 0, clientY: 0 });
fireEvent.pointerUp(stage);
expect(store().selection).toEqual(['b']);
});
});
describe('the marquee', () => {
it('picks what it touches, with its group, and is drawn while it is dragged', () => {
load(grouped());
const { container, stage, sheet } = draw();
fireEvent.pointerDown(sheet, { clientX: 0, clientY: 0 });
fireEvent.pointerMove(stage, { clientX: 70 * PX, clientY: 25 * PX });
expect(container.querySelector('.st-marquee')).not.toBeNull();
expect(store().selection).toEqual(['a', 'g1', 'g2']);
fireEvent.pointerUp(stage);
expect(container.querySelector('.st-marquee')).toBeNull();
expect(store().selection).toEqual(['a', 'g1', 'g2']);
});
it('adds to the selection with shift, and a click on bare paper clears it', () => {
load([shape('a', 10, 10), shape('b', 100, 100)]);
const { stage, sheet } = draw();
store().select(['b']);
fireEvent.pointerDown(sheet, { clientX: 0, clientY: 0, shiftKey: true });
fireEvent.pointerMove(stage, { clientX: 40 * PX, clientY: 40 * PX, shiftKey: true });
fireEvent.pointerUp(stage);
expect(store().selection).toEqual(['b', 'a']);
fireEvent.pointerDown(sheet, { clientX: 5, clientY: 5 });
fireEvent.pointerUp(stage);
expect(store().selection).toEqual([]);
});
it('starts from a locked element dragged, and leaves the locked one out', () => {
load([shape('bg', 0, 0, { locked: true, frame: { x: 0, y: 0, w: 200, h: 200 } }), shape('a', 10, 10)]);
const { stage, targets } = draw();
fireEvent.pointerDown(must(targets()[0]), { clientX: 1, clientY: 1 });
fireEvent.pointerMove(stage, { clientX: 40 * PX, clientY: 40 * PX });
fireEvent.pointerUp(stage);
expect(store().selection).toEqual(['a']);
expect(el('bg').frame).toMatchObject({ x: 0, y: 0 });
});
});
describe('resizing a multi-selection', () => {
const grab = () => {
load([shape('a', 10, 10), shape('b', 50, 30)]);
store().select(['a', 'b']);
const view = draw();
const handles = Array.from(view.container.querySelectorAll('.st-select-box .st-handle')) as HTMLElement[];
// The handles run nw, n, ne, e, se, s, sw, w.
return { ...view, se: must(handles[4]) };
};
it('scales every frame inside one box', () => {
const { stage, se } = grab();
fireEvent.pointerDown(se, { clientX: 0, clientY: 0 });
fireEvent.pointerMove(stage, { clientX: 60 * PX, clientY: 0 });
fireEvent.pointerUp(stage);
// The box 10..70 grows to 10..130 across, so everything doubles that way.
expect(el('a').frame.w).toBeCloseTo(40);
expect(el('b').frame.x).toBeCloseTo(90);
expect(el('b').frame.h).toBeCloseTo(20);
store().undo();
expect(el('a').frame).toMatchObject({ w: 20, h: 20 });
});
it('keeps the proportions with shift, so every element keeps its own', () => {
const { stage, se } = grab();
fireEvent.pointerDown(se, { clientX: 0, clientY: 0 });
fireEvent.pointerMove(stage, { clientX: 120 * PX, clientY: 0, shiftKey: true });
fireEvent.pointerUp(stage);
// 60 by 40 to 180 by 120: three times either way.
expect(el('a').frame.w).toBeCloseTo(60);
expect(el('a').frame.h).toBeCloseTo(60);
expect(el('b').frame.y).toBeCloseTo(70);
});
it('leaves a locked member where it is', () => {
load([shape('a', 10, 10), shape('l', 50, 30, { locked: true })]);
store().select(['a', 'l']);
const { container, stage } = draw();
const se = Array.from(container.querySelectorAll('.st-select-box .st-handle'))[4] as HTMLElement;
fireEvent.pointerDown(se, { clientX: 0, clientY: 0 });
fireEvent.pointerMove(stage, { clientX: 20 * PX, clientY: 20 * PX });
fireEvent.pointerUp(stage);
expect(el('l').frame).toMatchObject({ x: 50, y: 30, w: 20, h: 20 });
expect(el('a').frame.w).toBeCloseTo(40);
});
});
describe('the keyboard', () => {
it('nudges by a millimetre and ten with shift, one undo step while held', () => {
load([shape('a', 10, 10)]);
store().select(['a']);
draw();
fireEvent.keyDown(document, { key: 'ArrowRight' });
fireEvent.keyDown(document, { key: 'ArrowRight', repeat: true });
fireEvent.keyDown(document, { key: 'ArrowDown', shiftKey: true });
fireEvent.keyUp(document, { key: 'ArrowDown' });
expect(el('a').frame).toMatchObject({ x: 12, y: 20 });
store().undo();
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
});
it('groups, ungroups, duplicates and selects everything', () => {
load([shape('a', 10, 10), shape('b', 40, 10)]);
draw();
fireEvent.keyDown(document, { key: 'a', ctrlKey: true });
expect(store().selection).toEqual(['a', 'b']);
fireEvent.keyDown(document, { key: 'g', ctrlKey: true });
expect(el('a').group).toBeDefined();
fireEvent.keyDown(document, { key: 'G', ctrlKey: true, shiftKey: true });
expect(el('a').group).toBeUndefined();
fireEvent.keyDown(document, { key: 'd', ctrlKey: true });
expect(elements()).toHaveLength(4);
});
it('copies and pastes style, and arranges with the brackets', () => {
load([shape('a', 10, 10, { fill: '#ff0000' }), shape('b', 40, 10)]);
draw();
store().select(['a']);
fireEvent.keyDown(document, { key: 'c', ctrlKey: true, altKey: true });
store().select(['b']);
fireEvent.keyDown(document, { key: 'v', ctrlKey: true, altKey: true });
expect(el('b')).toMatchObject({ fill: '#ff0000' });
fireEvent.keyDown(document, { key: '[', ctrlKey: true });
expect(elements().map((e) => e.id)).toEqual(['b', 'a']);
fireEvent.keyDown(document, { key: '}', ctrlKey: true, shiftKey: true });
expect(elements().map((e) => e.id)).toEqual(['a', 'b']);
});
it('steps out of a group with Escape, telling the page not to clear the selection', () => {
load(grouped());
draw();
store().enterGroup(0, 'g1');
const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
document.dispatchEvent(escape);
expect(escape.defaultPrevented).toBe(true);
expect(store().selection).toEqual(['g1', 'g2']);
const again = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
document.dispatchEvent(again);
expect(again.defaultPrevented).toBe(false);
});
it('leaves the keys to a field being typed in', () => {
load([shape('a', 10, 10)]);
store().select(['a']);
draw();
const input = document.createElement('input');
document.body.appendChild(input);
fireEvent.keyDown(input, { key: 'ArrowRight' });
fireEvent.keyDown(input, { key: 'a', ctrlKey: true });
expect(el('a').frame.x).toBe(10);
input.remove();
});
});
+60 -60
View File
@@ -1,21 +1,16 @@
import { useState } from 'react'
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 { bookElementId as elementId } from '@trek/shared'
import { fontStack, nearestWeight } from './bookFonts'
import { LineHandles } from './LineHandles'
import { useSpreadInteraction } from './useSpreadInteraction'
import { useStudioKeys } from './useStudioKeys'
import { selectClass, selectionShape } from './studioGestures'
import { MarqueeRect, ResizeHandles, SelectionBox } from './StudioSelectionChrome'
import '../../styles/studio-layers.css'
import { fontStack } from './bookFonts'
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
import { useStudioStore } from '../../store/studioStore'
import { useTranslation } from '../../i18n'
import { STICKER_DRAG_TYPE, stickerDropped } from './stickerLibrary'
const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
/**
* What a delete may actually take out of the selection.
@@ -42,6 +37,17 @@ const ROTATE_CORNERS = [
{ id: 'sw', left: '0%', top: '100%' },
] as const
const HANDLE_POS: Record<HandleId, { left: string; top: string; cursor: string }> = {
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.
*
@@ -91,9 +97,10 @@ export function StudioCanvas({
const select = useStudioStore(s => s.select)
const removeElements = useStudioStore(s => s.removeElements)
const duplicate = useStudioStore(s => s.duplicate)
const copy = useStudioStore(s => s.copy)
const paste = useStudioStore(s => s.paste)
const hasClipboard = useStudioStore(s => !!s.clipboard)
const raise = useStudioStore(s => s.raise)
const enteredGroup = useStudioStore(s => s.enteredGroup)
const enterGroup = useStudioStore(s => s.enterGroup)
const commit = useStudioStore(s => s.commit)
const addElement = useStudioStore(s => s.addElement)
const [dropTarget, setDropTarget] = useState<string | null>(null)
@@ -133,25 +140,45 @@ export function StudioCanvas({
const frameUnder = (x: number, y: number) => spread?.elements.find(el =>
el.kind === 'photo'
&& !el.locked
&& !el.hidden
&& 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, marquee, startMove, startPaper, startResize, startRotate, onPointerMove, finish } = useSpreadInteraction({
const { guides, dragging, startMove, startResize, startRotate, onPointerMove, finish } = useSpreadInteraction({
spread, spreadIndex, page, pxPerMm: scaled,
})
useStudioKeys({ spreadIndex, editing: editing !== null })
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()
const ids = deletable(spread, selection)
if (ids.length) removeElements(spreadIndex, ids)
}
// Copy and paste across pages (#2316). Text selected on the page is the
// browser's to copy, not the book's.
if (!(e.metaKey || e.ctrlKey) || e.altKey) return
const key = e.key.toLowerCase()
if (key === 'c' && selection.length && !window.getSelection()?.toString()) {
e.preventDefault()
copy(spreadIndex, selection)
} else if (key === 'v' && hasClipboard) {
e.preventDefault()
paste(spreadIndex)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [selection, spread, spreadIndex, removeElements, copy, paste, hasClipboard])
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) && !e.hidden)
/** One box with handles around a multi-selection; a whole group reads as one thing. */
const { many, box, asGroup, anyMovable } = selectionShape(spread, sel)
const sel = spread.elements.filter(e => selection.includes(e.id))
return (
<div
@@ -169,12 +196,6 @@ export function StudioCanvas({
onPointerUp={finish}
onPointerCancel={finish}
onDragOver={e => {
// A sticker from its panel lands where it is let go; it fills no frame.
if (e.dataTransfer.types.includes(STICKER_DRAG_TYPE)) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
return
}
const fromPanel = e.dataTransfer.types.includes('application/x-trek-photo')
// A file drag is only claimed when somebody is there to take it; for a
// viewer the browser keeps its default and opens the picture itself.
@@ -188,12 +209,6 @@ export function StudioCanvas({
}}
onDragLeave={() => { setDropTarget(null); setFileOver(false) }}
onDrop={e => {
const sticker = stickerDropped(e.dataTransfer.getData(STICKER_DRAG_TYPE), page, single, pointInMm(e))
if (sticker) {
e.preventDefault()
addElement(spreadIndex, sticker)
return
}
const raw = e.dataTransfer.getData('application/x-trek-photo')
const files = Array.from(e.dataTransfer.files ?? [])
if (!raw && !(files.length && onDropFiles)) return
@@ -256,8 +271,7 @@ export function StudioCanvas({
height: `${page.pageHeight}mm`,
transform: `scale(${zoom})`,
}}
// A press on bare paper draws a marquee; without a drag it clears the selection.
onPointerDown={startPaper}
onPointerDown={() => select([])}
>
<SpreadView spread={spread} page={page} folios={folios} big={zoom > 0.34} showGuides dropLabel={dropLabel} />
@@ -267,15 +281,11 @@ export function StudioCanvas({
{/* Hit targets sit above the page so a photo's own <img> never eats the
gesture, and so a locked element simply is not grabbable. */}
{spread.elements.filter(el => !el.hidden).map(el => (
{spread.elements.map(el => (
<div
key={el.id}
onPointerDown={e => { if (editing !== el.id) startMove(e, el) }}
onDoubleClick={() => {
// Into the group first, the way design tools do it; a second one edits a text.
if (el.group && enteredGroup !== el.group) { enterGroup(spreadIndex, el.id); return }
if (el.kind === 'text' && !el.locked) setEditing(el.id)
}}
onDoubleClick={() => { if (el.kind === 'text' && !el.locked) setEditing(el.id) }}
style={{
position: 'absolute',
left: `${el.frame.x}mm`,
@@ -315,7 +325,7 @@ export function StudioCanvas({
// The document is in pt; the overlay is on screen at the same zoom.
fontSize: `${el.size * (96 / 72) * zoom}px`,
fontFamily: fontStack(el.font),
fontWeight: nearestWeight(el.font, el.weight),
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
@@ -369,7 +379,7 @@ export function StudioCanvas({
{sel.map(el => (
<div
key={el.id}
className={selectClass(many, el, enteredGroup)}
className="st-select"
style={{
left: el.frame.x * scaled,
top: el.frame.y * scaled,
@@ -409,27 +419,17 @@ export function StudioCanvas({
</div>
))}
{/* A line is shaped by its points rather than its box. */}
{sel.length === 1 && !el.locked && el.kind === 'line' && (
<LineHandles el={el} spreadIndex={spreadIndex} scaled={scaled} t={t} />
)}
{sel.length === 1 && !dragging && !el.locked && el.kind !== 'line' && <ResizeHandles onResize={startResize} />}
{sel.length === 1 && !dragging && !el.locked && HANDLES.map(h => (
<span
key={h}
className={`st-handle ${h.length === 2 ? 'is-corner' : h === 'n' || h === 's' ? 'is-h' : 'is-v'}`}
style={{ left: HANDLE_POS[h].left, top: HANDLE_POS[h].top, cursor: HANDLE_POS[h].cursor }}
onPointerDown={e => startResize(e, h)}
/>
))}
</div>
))}
{/*
A multi-selection as one box: one set of handles that resizes them
together (Shift keeps the proportions), the members outlined lightly
inside it. A whole group's box is dashed, so a group reads as a group
before anything is done to it.
*/}
{box && (
<SelectionBox box={box} scaled={scaled} asGroup={asGroup} handles={!dragging && anyMovable} onResize={startResize} />
)}
{marquee && <MarqueeRect rect={marquee} scaled={scaled} />}
{/*
The quick bar, the way Canva and Figma do it: the three or four things
you reach for constantly, right where you are already looking, instead
@@ -450,12 +450,12 @@ export function StudioCanvas({
<button type="button" onClick={() => duplicate(spreadIndex, selection)} title={t('journey.studio.duplicate')}>
<Copy size={14} />
</button>
{(sel.length === 1 || asGroup) && (
{sel.length === 1 && (
<>
<button type="button" onClick={() => raise(spreadIndex, selection, 'front')} title={t('journey.studio.toFront')}>
<button type="button" onClick={() => raise(spreadIndex, sel[0].id, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button type="button" onClick={() => raise(spreadIndex, selection, 'back')} title={t('journey.studio.toBack')}>
<button type="button" onClick={() => raise(spreadIndex, sel[0].id, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</>
@@ -1,89 +0,0 @@
import {
BOOK_EDGES,
BOOK_EDGE_SIZE_MAX,
BOOK_EDGE_SIZE_MIN,
type BookEdge,
type BookShapeId,
bookShapeTakesEdge,
} from '@trek/shared';
import { NumField, Section } from './StudioControls';
import { DEFAULT_EDGE_SIZE } from './shapeOutline';
import { edgedPath } from './shapes-edges';
/**
* The edge of a shape or of a photo's cut: a row of tiles, each the edge drawn
* on a little card, and how deep it bites.
*
* Shown for the shapes an edge is cut along and for photos. On an outline that
* takes none (a heart, a star) it stays away, unless an edge is already set
* there, which it then says does nothing and offers to put back.
*/
const CARD = 'M0 0L40 0L40 30L0 30Z';
/** One edge, drawn on a card the size of a tile. Seeded alike, so the torn tile does not change while you look at it. */
function EdgeGlyph({ edge }: { edge: BookEdge }) {
return (
<svg viewBox="-2 -2 44 34" aria-hidden>
<path d={edgedPath(CARD, edge, 5, 3)} fill="currentColor" />
</svg>
);
}
export function EdgeSection({
edge,
edgeSize,
outline,
onSet,
t,
}: {
edge: BookEdge | undefined;
edgeSize: number | undefined;
/** The shape the edge would be cut along; null for a photo's plain rectangle. */
outline: BookShapeId | null;
onSet: (patch: { edge?: BookEdge; edgeSize?: number }) => void;
t: (k: string) => string;
}) {
const current = edge ?? 'straight';
const takes = bookShapeTakesEdge(outline);
// A heart or a star keeps its drawn line, so there is nothing to offer unless an edge is already set on one.
if (!takes && current === 'straight') return null;
return (
<Section label={t('journey.studio.edgeStyle')} defaultOpen={current !== 'straight'}>
{!takes && (
<p className="st-hint" style={{ paddingBottom: 8 }}>
{t('journey.studio.edgeUnavailable')}
</p>
)}
<div className="st-mini-shapes">
{BOOK_EDGES.map((e) => (
<button
type="button"
key={e}
className={`st-mini-shape ${current === e ? 'is-on' : ''}`}
onClick={() => onSet({ edge: e })}
title={t(`journey.studio.edge.${e}`)}
aria-label={t(`journey.studio.edge.${e}`)}
aria-pressed={current === e}
>
<EdgeGlyph edge={e} />
</button>
))}
</div>
{takes && current !== 'straight' && (
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.edgeSize')}
value={edgeSize ?? DEFAULT_EDGE_SIZE}
min={BOOK_EDGE_SIZE_MIN}
max={BOOK_EDGE_SIZE_MAX}
step={0.5}
unit="mm"
onChange={(v) => onSet({ edgeSize: v })}
/>
</div>
)}
</Section>
);
}
@@ -2,15 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Minus, Quote, Search, Square, X } from 'lucide-react'
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS } from './shapes'
import { BOOK_GRIDS as GRIDS, bookElementId, bookShapeSize, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared'
import { shapeTile } from './shapeOutline'
import { StudioLineShelf } from './StudioLineShelf'
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, SHAPE_PATHS } from './shapes'
import { BOOK_GRIDS as GRIDS, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared'
import { PanelHead } from './StudioPanelHead'
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
import '../../styles/studio-elements.css'
import { StudioTextStyles } from './StudioTextStyles'
import type { ShelfSource } from './textStylesModel'
/**
* Everything you can add that does not come from the journey: type, shapes,
@@ -21,8 +16,7 @@ import type { ShelfSource } from './textStylesModel'
* hold them separately stops being faster to hit than a word would be.
*/
// The book's own ids, the ones the layouts and the assistant mint (shared/src/book/book-ids.ts).
const uid = bookElementId
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
/** How many icons the grid draws at a time. Two screens' worth at four across. */
const ICON_PAGE = 120
@@ -39,9 +33,8 @@ type FrameStyle = 'none' | 'polaroid' | 'white' | 'shadow' | 'film' | 'tape'
*/
export function ShapeGlyph({ shape, photo = false }: { shape: BookShapeId; photo?: boolean }) {
const gid = `sg-${photo ? 'p' : 's'}-${shape}`
const tile = shapeTile(shape)
return (
<svg viewBox={`0 0 ${tile.w} ${tile.h}`} width="100%" height="100%" preserveAspectRatio="xMidYMid meet" aria-hidden>
<svg viewBox="0 0 100 100" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" aria-hidden>
{photo && (
<defs>
{/* theme-lint-disable — a frame preview stands in for a photograph, not for UI chrome */}
@@ -54,7 +47,7 @@ export function ShapeGlyph({ shape, photo = false }: { shape: BookShapeId; photo
</defs>
)}
<path
d={tile.d}
d={SHAPE_PATHS[shape]}
fill={photo ? `url(#${gid})` : 'currentColor'}
fillRule={HOLED_SHAPES.has(shape) ? 'evenodd' : undefined}
/>
@@ -99,55 +92,8 @@ function IconCell({ name, onPick }: { name: string; onPick: (name: string) => vo
)
}
/** A plain text style: size, weight, its sample words and anything else it sets. */
type AddText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra?: Partial<BookElement>) => void
/** The type tab: the four plain styles, then the lockups of the Text styles shelf. */
function TextTab({ page, source, t, addText }: {
page: BookPageSetup
source: ShelfSource
t: (k: string) => string
addText: AddText
}) {
return (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.text')}</div>
<div className="st-stack">
<button type="button" className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
{t('journey.studio.styleTitle')}
</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
</button>
<button type="button"
className="st-tile"
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
>
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
{t('journey.studio.styleCaption')}
</span>
</button>
</div>
</div>
<StudioTextStyles page={page} source={source} t={t} />
</>
)
}
export function StudioElementsPanel({ page, t, source }: {
page: BookPageSetup
t: (k: string) => string
/** The journal, for the year and the point a lockup is filled with. */
source: ShelfSource
}) {
const [tab, setTab] = useState<'text' | 'shapes' | 'frames' | 'grids' | 'icons'>('text')
export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: string) => string }) {
const [tab, setTab] = useState<'shapes' | 'frames' | 'grids' | 'icons'>('shapes')
const [iconQuery, setIconQuery] = useState('')
/*
* How much of the library is on screen.
@@ -197,12 +143,10 @@ export function StudioElementsPanel({ page, t, source }: {
|| shape === 'capsule'
const tall = shape === 'pin' || shape === 'drop' || shape === 'arrow-up' || shape === 'arrow-down'
const isRule = shape === 'line'
const own = bookShapeSize(shape, side)
addElement(active, {
id: uid('s'), kind: 'shape',
frame: isRule
? centre(page.pageWidth * 0.5, 0.5)
: own ? centre(own.w, own.h)
: centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side),
rotation: 0, opacity: 1, locked: false,
// A rule is a shape too — a hairline box rather than its own element type,
@@ -271,10 +215,7 @@ export function StudioElementsPanel({ page, t, source }: {
<>
<PanelHead label={t('journey.studio.elements')} />
<div className="st-tabs is-fluid">
<button type="button" className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
{t('journey.studio.text')}
</button>
<div className="st-tabs">
<button type="button" className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}>
{t('journey.studio.shapes')}
</button>
@@ -290,14 +231,33 @@ export function StudioElementsPanel({ page, t, source }: {
</div>
<div className="st-panel-scroll">
{/*
Type first: the plain styles, then the lockups, set titles and marks
drawn with the book's own words (StudioTextStyles.tsx).
*/}
{tab === 'text' && <TextTab page={page} source={source} t={t} addText={addText} />}
{tab === 'shapes' && (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.text')}</div>
<div className="st-stack">
<button type="button" className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
{t('journey.studio.styleTitle')}
</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
</button>
<button type="button"
className="st-tile"
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
>
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
{t('journey.studio.styleCaption')}
</span>
</button>
</div>
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.lines')}</div>
<div className="st-shape-grid">
@@ -321,8 +281,6 @@ export function StudioElementsPanel({ page, t, source }: {
</div>
</div>
<StudioLineShelf page={page} single={single} onAdd={el => addElement(active, el)} t={t} />
{SHAPE_GROUPS.map(group => (
<div className="st-section" key={group.id}>
<div className="st-section-label">{t(`journey.studio.shapeGroup.${group.id}`)}</div>
@@ -1,112 +0,0 @@
import { blankBook, bookTextElementSchema, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, within } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { BOOK_FONT_ORDER } from './bookFonts';
import { matchFonts, pickFont } from './fontPicker';
import { StudioFontPicker } from './StudioFontPicker';
/**
* The typeface picker (FE-STUDIO-FONTPICK): the current family set in itself,
* a list grouped by kind with every family in its own face, the book's own
* families first, a search, and a weight that lands on one the family has.
*/
const t = (k: string, params?: Record<string, string | number>) =>
params ? `${k} ${Object.values(params).join(' ')}` : k;
const groupName = (c: string) => `journey.studio.fonts.cat.${c}`;
const frame = { x: 10, y: 10, w: 80, h: 20 };
function bookIn(...fonts: string[]): BookDocument {
const doc = blankBook({ title: 'Porto', newId: (p) => `${p}-x` });
const elements = fonts.map(
(font, i) => bookTextElementSchema.parse({ id: `t${i}`, kind: 'text', frame, text: 'Porto', font }) as BookElement
);
const [cover, ...rest] = doc.spreads as [BookSpread, ...BookSpread[]];
return {
...doc,
page: { ...doc.page, pageNumbers: { ...doc.page.pageNumbers, show: false } },
spreads: [{ ...cover, elements }, ...rest],
};
}
const open = () => fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.fonts\.change/ }));
afterEach(() => {
useStudioStore.setState({ doc: null });
});
describe('choosing a family', () => {
it('FE-STUDIO-FONTPICK-001: moves the weight onto one the new family has', () => {
expect(pickFont('anton', 700)).toEqual({ font: 'anton', weight: 400 });
expect(pickFont('sans', 700)).toEqual({ font: 'sans', weight: 700 });
expect(pickFont('oswald', 900)).toEqual({ font: 'oswald', weight: 700 });
});
it('FE-STUDIO-FONTPICK-002: finds families by name, id and group, in picker order', () => {
expect(matchFonts('', groupName)).toEqual(BOOK_FONT_ORDER);
expect(matchFonts(' GARA ', groupName)).toEqual(['garamond', 'cormorant']);
expect(matchFonts('mono', groupName)).toEqual(['mono']);
expect(matchFonts('cat.script', groupName)).toContain('pacifico');
expect(matchFonts('nothing like it', groupName)).toEqual([]);
});
});
describe('the picker', () => {
it('FE-STUDIO-FONTPICK-003: shows the current family set in itself and opens a grouped list with the book families first', () => {
act(() => useStudioStore.setState({ doc: bookIn('caveat', 'serif') }));
render(<StudioFontPicker font="serif" weight={400} onPick={vi.fn()} t={t} />);
const current = screen.getByRole('button', { name: 'journey.studio.fonts.change: Lora' });
expect(current.getAttribute('aria-expanded')).toBe('false');
expect(within(current).getByText('Lora').getAttribute('style')).toContain('Lora');
open();
expect(current.getAttribute('aria-expanded')).toBe('true');
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'journey.studio.fonts.search' }));
const groups = screen.getAllByRole('group');
expect(groups[0]?.getAttribute('aria-label')).toBe('journey.studio.fonts.used');
const used = within(groups[0] as HTMLElement).getAllByRole('option');
expect(used.map((o) => o.querySelector('.st-fontpick-face')?.textContent)).toEqual(['Lora', 'Caveat']);
expect(used[1]?.textContent).toContain('journey.studio.fonts.range 400 700');
// Every family, once per group it is in, each set in its own face.
const rows = screen.getAllByRole('option');
expect(rows).toHaveLength(BOOK_FONT_ORDER.length + 2);
const anton = rows.find((r) => r.textContent?.startsWith('Anton'));
expect(anton?.querySelector('span')?.getAttribute('style')).toContain('Anton');
expect(anton?.textContent).toContain('400');
expect(rows.filter((r) => r.getAttribute('aria-selected') === 'true')).toHaveLength(2);
});
it('FE-STUDIO-FONTPICK-004: picks a family at a weight it has and closes, back on the button', () => {
const onPick = vi.fn();
render(<StudioFontPicker font="sans" weight={700} onPick={onPick} t={t} />);
open();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'anton' } });
expect(screen.queryByRole('group', { name: 'journey.studio.fonts.used' })).toBeNull();
fireEvent.click(screen.getByText('Anton'));
expect(onPick).toHaveBeenCalledWith({ font: 'anton', weight: 400 });
expect(screen.queryByRole('listbox')).toBeNull();
expect(document.activeElement?.getAttribute('aria-expanded')).toBe('false');
});
it('FE-STUDIO-FONTPICK-005: says when nothing matches, clears the search, and closes on Escape', () => {
render(<StudioFontPicker font="sans" weight={400} onPick={vi.fn()} t={t} />);
open();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzz' } });
expect(screen.getByText('journey.studio.fonts.none')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'common.clear' }));
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('');
expect(screen.queryByText('journey.studio.fonts.none')).toBeNull();
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Escape' });
expect(screen.queryByRole('listbox')).toBeNull();
// A second click on the open button closes it too.
open();
open();
expect(screen.queryByRole('listbox')).toBeNull();
});
it('FE-STUDIO-FONTPICK-006: reads a family this build does not know as the default one', () => {
render(<StudioFontPicker font="gone" weight={400} onPick={vi.fn()} t={t} />);
expect(screen.getByRole('button', { name: 'journey.studio.fonts.change: Poppins' })).toBeTruthy();
});
});
@@ -1,198 +0,0 @@
import { BOOK_FONT_CATEGORIES, bookFontsUsed, type BookFontCategory } from '@trek/shared';
import { Check, ChevronDown, Search, X } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-fonts.css';
import { BOOK_FONTS, nearestWeight, type BookFontId } from './bookFonts';
import { matchFonts, pickFont, weightSpan, type FontPick, type FontT } from './fontPicker';
/**
* The typeface picker, shared by the text inspector and the travel inspector.
*
* Thirty four families do not fit as a wall of buttons the way seven did, and
* a plain list of names says nothing about how a face looks, which is the only
* thing anyone is choosing between. So the current family sits on one line,
* set in itself, and opens a list grouped the way people look for type
* (a sans, a serif, something for a poster, a script to lay over a photo)
* where every family is again set in its own face. The families the book is
* already set in come first, because the second text of a book is usually set
* like the first.
*
* The list opens inside the inspector rather than floating over the page: the
* inspector scrolls, and a popover pinned to a control that scrolls away is a
* popover pointing at nothing.
*/
function FontRow({
id,
on,
weight,
onPick,
t,
}: {
id: BookFontId;
on: boolean;
weight: number;
onPick: () => void;
t: FontT;
}) {
const face = BOOK_FONTS[id];
return (
<button
type="button"
role="option"
aria-selected={on}
className={`st-fontpick-row ${on ? 'is-on' : ''}`}
onClick={onPick}
title={face.name}
>
<span className="st-fontpick-face" style={{ fontFamily: face.stack, fontWeight: nearestWeight(id, weight) }}>
{face.name}
</span>
<span className="st-fontpick-span">{weightSpan(id, t)}</span>
<span className="st-fontpick-tick">{on && <Check size={13} />}</span>
</button>
);
}
function FontGroup({
label,
ids,
font,
weight,
onPick,
t,
}: {
label: string;
ids: BookFontId[];
font: string;
weight: number;
onPick: (id: BookFontId) => void;
t: FontT;
}) {
if (!ids.length) return null;
return (
<div className="st-fontpick-group" role="group" aria-label={label}>
<div className="st-fontpick-label">{label}</div>
{ids.map((id) => (
<FontRow key={id} id={id} on={font === id} weight={weight} onPick={() => onPick(id)} t={t} />
))}
</div>
);
}
/** The families the open book is set in, read from the document the Studio holds. */
function useFontsInBook(): BookFontId[] {
const doc = useStudioStore((s) => s.doc);
return useMemo(() => (doc ? bookFontsUsed(doc) : []), [doc]);
}
export function StudioFontPicker({
font,
weight,
onPick,
t,
}: {
font: string;
weight: number;
onPick: (patch: FontPick) => void;
t: FontT;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const trigger = useRef<HTMLButtonElement>(null);
const search = useRef<HTMLInputElement>(null);
const listId = useId();
const inBook = useFontsInBook();
const current = BOOK_FONTS[font as BookFontId] ?? BOOK_FONTS.sans;
const groupName = (c: BookFontCategory) => t(`journey.studio.fonts.cat.${c}`);
const found = matchFonts(query, groupName);
// Opened on purpose, so typing goes straight to the search.
useEffect(() => {
if (open) search.current?.focus();
}, [open]);
const close = () => {
setOpen(false);
setQuery('');
trigger.current?.focus();
};
const choose = (id: BookFontId) => {
onPick(pickFont(id, weight));
close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
close();
};
return (
<div className={`st-fontpick ${open ? 'is-open' : ''}`} onKeyDown={onKey}>
<button
ref={trigger}
type="button"
className="st-fontpick-current"
aria-expanded={open}
aria-controls={listId}
aria-label={`${t('journey.studio.fonts.change')}: ${current.name}`}
onClick={() => (open ? close() : setOpen(true))}
>
<span
className="st-fontpick-face"
style={{ fontFamily: current.stack, fontWeight: nearestWeight(current.id, weight) }}
>
{current.name}
</span>
<span className="st-fontpick-span">{groupName(current.category)}</span>
<ChevronDown size={14} className="st-fontpick-caret" />
</button>
{open && (
<div className="st-fontpick-panel">
<div className="st-search st-fontpick-search">
<Search size={14} />
<input
ref={search}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('journey.studio.fonts.search')}
aria-label={t('journey.studio.fonts.search')}
aria-controls={listId}
spellCheck={false}
/>
{query && (
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
<div id={listId} className="st-fontpick-list" role="listbox" aria-label={t('journey.studio.fonts.list')}>
{!query && (
<FontGroup
label={t('journey.studio.fonts.used')}
ids={inBook}
font={font}
weight={weight}
onPick={choose}
t={t}
/>
)}
{BOOK_FONT_CATEGORIES.map((category) => (
<FontGroup
key={category}
label={groupName(category)}
ids={found.filter((id) => BOOK_FONTS[id].category === category)}
font={font}
weight={weight}
onPick={choose}
t={t}
/>
))}
{!found.length && <p className="st-hint st-fontpick-none">{t('journey.studio.fonts.none')}</p>}
</div>
</div>
)}
</div>
);
}
@@ -9,25 +9,13 @@ import { photoSrc } from './bookRender'
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
import { ShapeGlyph } from './StudioElementsPanel'
import { TravelInspector } from './StudioTravelInspector'
import { StudioStickerInspector } from './StudioStickerInspector'
import { Swatches } from './StudioSwatches'
import { StudioBackgroundSection } from './StudioBackgroundSection'
import { StudioShapePaint } from './StudioShapePaint'
import { MAX_BOOK_TEXT_SIZE, formatBookCoords, withThemeLinks } from '@trek/shared'
import { formatBookCoords } from '@trek/shared'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
import { italicTitle } from './fontPicker'
import { PhotoCropExtras, PhotoLookSections } from './photo/StudioPhotoLook'
import { StudioTextEffects } from './StudioTextEffects'
import { FontButtons, WeightLine } from './StudioTypeControls'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
import { StudioArrangeSection } from './StudioArrangeSection'
import { selectionTitle } from './studioLayerRows'
import { EdgeSection } from './StudioEdgeControls'
import { LineInspector } from './StudioLineInspector'
import { StudioThemeLink } from './theme/StudioThemeLink'
/** How many icons the swap grid offers at once. A search narrows it below this. */
const ICON_CHOICES = 60
@@ -112,8 +100,6 @@ export function StudioInspector({
<aside className="st-panel st-inspector">
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div>
<div className="st-panel-scroll">
<StudioBackgroundSection spreadIndex={spreadIndex} page={page} source={source} t={t} />
<Section label={t('journey.studio.pageNumbers')}>
<Switch
label={t('journey.studio.pageNumbers')}
@@ -204,8 +190,7 @@ export function StudioInspector({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
// A colour or face set by hand stops following the theme; a link set here takes the theme's value.
elements: sp.elements.map(e => (selection.includes(e.id) ? withThemeLinks({ ...e, ...patch } as BookElement, patch, d.theme) : e)),
elements: sp.elements.map(e => (selection.includes(e.id) ? ({ ...e, ...patch } as BookElement) : e)),
})),
}))
@@ -248,7 +233,7 @@ export function StudioInspector({
*/}
<div className="st-panel-head st-inspector-head">
<span className="st-what">
{one ? t(`journey.studio.kind.${el.kind}`) : selectionTitle(spread, selection, t)}
{one ? t(`journey.studio.kind.${el.kind}`) : t('journey.studio.multiple')}
{sel.length > 1 && <em>{sel.length}</em>}
</span>
<span className="st-head-acts">
@@ -338,7 +323,7 @@ export function StudioInspector({
nothing about a typeface — and grouped, because "which serif"
is a different question from "serif or sans".
*/}
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch)} t={t} />
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch)} />
<div style={{ marginTop: 10 }}>
<WeightLine font={el.font} weight={el.weight} onPick={weight => set({ weight })} t={t} />
<Line label={t('journey.studio.align')}>
@@ -359,7 +344,7 @@ export function StudioInspector({
<button type="button"
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
onClick={() => set({ italic: !el.italic })}
title={italicTitle(el.font, el.weight, t)}
title={t('journey.studio.italic')}
aria-label={t('journey.studio.italic')}
aria-pressed={el.italic}
>
@@ -369,7 +354,7 @@ export function StudioInspector({
</Line>
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField label={t('journey.studio.size')} value={el.size} min={4} max={MAX_BOOK_TEXT_SIZE} step={0.5} unit="pt" onChange={v => set({ size: v })} />
<NumField label={t('journey.studio.size')} value={el.size} min={4} max={200} step={0.5} unit="pt" onChange={v => set({ size: v })} />
<NumField label={t('journey.studio.leading')} value={el.leading} min={0.7} max={3} step={0.05} onChange={v => set({ leading: v })} />
</div>
<div style={{ marginTop: 10 }}>
@@ -382,7 +367,7 @@ export function StudioInspector({
label={t('journey.studio.tracking')}
value={el.tracking}
min={-0.2}
max={1.5}
max={1}
step={0.01}
unit="em"
onChange={v => set({ tracking: v })}
@@ -393,12 +378,8 @@ export function StudioInspector({
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.text')} wide>
<Swatches value={el.color} onPick={c => set({ color: c })} />
<StudioThemeLink theme={doc?.theme} token={el.token} t={t}
onLink={(token, color) => set({ color, token })} onUnlink={() => set({ token: undefined })} />
</Line>
</Section>
<StudioTextEffects el={el} set={set} live={live} photos={source.photos} spread={spread} t={t} />
</>
)}
@@ -431,7 +412,6 @@ export function StudioInspector({
) : (
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.emptyFrame')}</p>
)}
<PhotoCropExtras el={el} set={set} live={live} t={t} />
</Section>
<Section label={t('journey.studio.look')}>
@@ -478,8 +458,6 @@ export function StudioInspector({
)}
</Section>
<PhotoLookSections el={el} set={set} live={live} t={t} />
<Section label={t('journey.studio.mask')} defaultOpen={false}>
<div className="st-mini-shapes">
<button type="button"
@@ -501,14 +479,6 @@ export function StudioInspector({
))}
</div>
</Section>
<EdgeSection
edge={el.edge}
edgeSize={el.edgeSize}
outline={el.mask && el.mask !== 'rect' ? el.mask : null}
onSet={patch => set(patch)}
t={t}
/>
</>
)}
@@ -545,17 +515,54 @@ export function StudioInspector({
</div>
</Section>
<StudioShapePaint
el={el}
spreadIndex={spreadIndex}
selection={selection}
onStroke={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
theme={doc?.theme}
set={set}
t={t}
/>
<EdgeSection edge={el.edge} edgeSize={el.edgeSize} outline={el.shape} onSet={patch => set(patch)} t={t} />
<Section label={t('journey.studio.colour')}>
{/*
Fill and stroke, both named. The panel used to show one
unlabelled swatch row that wrote `fill`, so on an outline-only
shape it lit up a colour that was not being drawn and there was
no way to reach the line you could actually see.
*/}
<Line label={t('journey.studio.fill')} wide>
<Swatches value={el.fill ?? '#111111'} onPick={c => set({ fill: c })} />
</Line>
<Line label={t('journey.studio.stroke')} wide>
<Swatches
value={el.stroke ?? '#141414'}
onPick={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.fillOn')}
on={el.fill !== null}
onToggle={() => set({
// Turning the fill off on a shape with no stroke would make
// it invisible, so the outline comes on with it.
fill: el.fill === null ? '#111111' : null,
...(el.fill !== null && !el.stroke ? { stroke: '#141414', strokeWidth: el.strokeWidth || 0.5 } : {}),
})}
/>
<Switch
label={t('journey.studio.gradient')}
on={el.gradient !== 'none'}
onToggle={() => set({ gradient: el.gradient === 'none' ? 'down' : 'none' })}
/>
</div>
{el.gradient !== 'none' && (
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.gradient')}>
<Choice
value={el.gradient}
options={[
{ value: 'down' as const, label: t('journey.studio.gradientDown') },
{ value: 'up' as const, label: t('journey.studio.gradientUp') },
]}
onPick={gradient => set({ gradient })}
/>
</Line>
</div>
)}
</Section>
<Section label={t('journey.studio.strokeStyle')} defaultOpen={false}>
<Line label={t('journey.studio.strokeStyle')} wide>
@@ -628,8 +635,6 @@ export function StudioInspector({
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.icon')} wide>
<Swatches value={el.color} onPick={c => set({ color: c })} />
<StudioThemeLink theme={doc?.theme} token={el.token} t={t}
onLink={(token, color) => set({ color, token })} onUnlink={() => set({ token: undefined })} />
</Line>
<div style={{ marginTop: 10 }}>
<NumField
@@ -645,9 +650,6 @@ export function StudioInspector({
</>
)}
{el.kind === 'sticker' && <StudioStickerInspector el={el} set={set} t={t} />}
{el.kind === 'line' && <LineInspector el={el} spreadIndex={spreadIndex} t={t} />}
<TravelInspector el={el} stats={stats} path={path} set={set} t={t} locale={locale} />
{/*
@@ -689,20 +691,18 @@ export function StudioInspector({
</Section>
)}
<StudioArrangeSection spreadIndex={spreadIndex} t={t} />
<Section label={t('journey.studio.arrange')} defaultOpen={false}>
<div className="st-row">
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'front')} title={t('journey.studio.toFront')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'up')} title={t('journey.studio.forward')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'up')} title={t('journey.studio.forward')}>
<ArrowUp size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'down')} title={t('journey.studio.backward')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'down')} title={t('journey.studio.backward')}>
<ArrowDown size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'back')} title={t('journey.studio.toBack')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</div>
@@ -742,7 +742,7 @@ export function StudioInspector({
* predict what "X 0.62" does to a photograph. Dragging the point over the actual
* image answers the question by showing it.
*/
export function FocalPad({
function FocalPad({
photoId, x, y, onDrag, onCommit, hint,
}: {
photoId: number
@@ -1,202 +0,0 @@
import type { BookDocument, BookElement } from '@trek/shared';
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { StudioArrangeSection } from './StudioArrangeSection';
import { selectionTitle } from './studioLayerRows';
import { StudioLayersPanel } from './StudioLayersPanel';
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
const t = (k: string) => k;
/** The value, or a failed test when it is missing. */
function must<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error('expected a value');
return value;
}
const shape = (id: string, x: number, over: Partial<BookElement> = {}): BookElement =>
bookShapeElementSchema.parse({
id,
kind: 'shape',
frame: { x, y: 10, w: 20, h: 20 },
shape: 'rect',
...over,
}) as BookElement;
function load(elements: BookElement[], groups?: { id: string; name?: string }[]) {
useStudioStore.getState().load({
version: 1,
title: 'T',
page,
spreads: [{ id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null, groups }],
} as BookDocument);
useStudioStore.setState({ enteredGroup: null, styleClip: null, readOnly: false });
}
const store = () => useStudioStore.getState();
const el = (id: string) => must(must(must(store().doc).spreads[0]).elements.find((e) => e.id === id));
const order = () => must(must(store().doc).spreads[0]).elements.map((e) => e.id);
const book = () => {
const title = bookTextElementSchema.parse({
id: 't',
kind: 'text',
frame: { x: 0, y: 0, w: 50, h: 10 },
text: 'Reykjavik',
}) as BookElement;
load(
[shape('a', 0), shape('g1', 40, { group: 'g' }), shape('g2', 80, { group: 'g' }), title],
[{ id: 'g', name: 'Pair' }]
);
};
const rows = (container: HTMLElement) => Array.from(container.querySelectorAll<HTMLElement>('[data-layer-key]'));
beforeEach(book);
describe('the layers panel', () => {
it('lists the page front to back, a group with its members, a text by its words', () => {
const { container } = render(<StudioLayersPanel t={t} />);
expect(rows(container).map((r) => r.dataset.layerKey)).toEqual(['t', 'g:g', 'g2', 'g1', 'a']);
expect(screen.getByText('Reykjavik')).toBeInTheDocument();
expect(screen.getByText('Pair')).toBeInTheDocument();
});
it('selects a group from its row, a member alone inside it, and an element with shift added', () => {
const { container } = render(<StudioLayersPanel t={t} />);
fireEvent.click(must(rows(container)[1]));
expect(store().selection).toEqual(['g1', 'g2']);
fireEvent.click(must(rows(container)[2]));
expect([store().selection, store().enteredGroup]).toEqual([['g2'], 'g']);
fireEvent.click(must(rows(container)[4]), { shiftKey: true });
expect(store().selection).toEqual(['g2', 'a']);
});
it('hides, shows and locks a layer from its row, and a group from its own', () => {
const { container } = render(<StudioLayersPanel t={t} />);
const aRow = must(rows(container)[4]);
fireEvent.click(aRow.querySelector('[aria-label="journey.studio.layers.hide"]') as Element);
expect(el('a').hidden).toBe(true);
expect(must(rows(container)[4]).className).toContain('is-hidden');
fireEvent.click(must(rows(container)[4]).querySelector('[aria-label="journey.studio.layers.show"]') as Element);
expect(el('a').hidden).toBeUndefined();
fireEvent.click(must(rows(container)[1]).querySelector('[aria-label="journey.studio.lock"]') as Element);
expect([el('g1').locked, el('g2').locked]).toEqual([true, true]);
// Toggling a row does not select it.
expect(store().selection).toEqual([]);
});
it('renames on a double-click, Enter keeps it and Escape leaves the name as it was', () => {
const { container } = render(<StudioLayersPanel t={t} />);
fireEvent.doubleClick(screen.getByText('Pair'));
const input = container.querySelector('.st-layer-input') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'Sun and sea' } });
fireEvent.blur(input);
expect(must(must(store().doc).spreads[0]).groups).toEqual([{ id: 'g', name: 'Sun and sea' }]);
fireEvent.doubleClick(screen.getByText('Reykjavik'));
const again = container.querySelector('.st-layer-input') as HTMLInputElement;
fireEvent.keyDown(again, { key: 'Escape' });
expect(container.querySelector('.st-layer-input')).toBeNull();
expect(el('t').name).toBeUndefined();
});
it('folds a group away and opens it again', () => {
const { container } = render(<StudioLayersPanel t={t} />);
fireEvent.click(container.querySelector('[aria-label="journey.studio.layers.collapse"]') as Element);
expect(rows(container).map((r) => r.dataset.layerKey)).toEqual(['t', 'g:g', 'a']);
fireEvent.click(container.querySelector('[aria-label="journey.studio.layers.expand"]') as Element);
expect(rows(container)).toHaveLength(5);
});
it('restacks a row dragged over another, as one undo step', () => {
const { container } = render(<StudioLayersPanel t={t} />);
const list = container.querySelector('.st-layers') as HTMLElement;
// Rows 20 pixels tall from the top, front to back.
rows(container).forEach((row, i) => {
row.getBoundingClientRect = () =>
({
top: i * 20,
bottom: i * 20 + 20,
height: 20,
left: 0,
right: 100,
width: 100,
x: 0,
y: i * 20,
toJSON: () => ({}),
}) as DOMRect;
});
fireEvent.pointerDown(must(rows(container)[4]), { clientY: 90, button: 0 });
fireEvent.pointerMove(list, { clientY: 60 });
fireEvent.pointerMove(list, { clientY: 3 });
expect(must(rows(container)[0]).className).toContain('is-drop-above');
fireEvent.pointerUp(list, { clientY: 3 });
expect(order()).toEqual(['g1', 'g2', 't', 'a']);
store().undo();
expect(order()).toEqual(['a', 'g1', 'g2', 't']);
});
it('groups and ungroups the selection from its foot, and says when the page is empty', () => {
store().select(['a', 't']);
render(<StudioLayersPanel t={t} />);
fireEvent.click(screen.getByText('journey.studio.layers.groupAction'));
expect(el('a').group).toBeDefined();
fireEvent.click(screen.getByText('journey.studio.layers.ungroup'));
expect(el('a').group).toBeUndefined();
load([]);
render(<StudioLayersPanel t={t} />);
expect(screen.getAllByText('journey.studio.layers.empty').length).toBeGreaterThan(0);
});
it('offers no drag, lock or visibility to a book held read-only', () => {
useStudioStore.setState({ readOnly: true });
const { container } = render(<StudioLayersPanel t={t} />);
expect(container.querySelector('.st-layer-acts')).toBeNull();
expect(container.querySelector('.st-layers-foot')).toBeNull();
});
});
describe('the align section', () => {
it('aligns a multi-selection within itself and distributes three', () => {
store().select(['a', 'g1', 'g2', 't']);
render(<StudioArrangeSection spreadIndex={0} t={t} />);
fireEvent.click(screen.getByLabelText('journey.studio.layers.alignTop'));
expect([el('a').frame.y, el('t').frame.y]).toEqual([0, 0]);
const steps = store().past.length;
fireEvent.click(screen.getByLabelText('journey.studio.layers.distributeH'));
expect(store().past.length).toBe(steps + 1);
});
it('aligns a single element against its page, and copies and pastes style', () => {
store().select(['a']);
render(<StudioArrangeSection spreadIndex={0} t={t} />);
// Folded for one element: lining up is not what one element usually needs next.
fireEvent.click(screen.getByText('journey.studio.layers.align'));
expect(screen.getByRole('radio', { name: 'journey.studio.layers.toSelection' })).toBeDisabled();
fireEvent.click(screen.getByLabelText('journey.studio.layers.alignRight'));
expect(el('a').frame.x).toBe(190);
expect(screen.getByLabelText('journey.studio.layers.pasteStyle')).toBeDisabled();
fireEvent.click(screen.getByLabelText('journey.studio.layers.copyStyle'));
expect(store().styleClip?.kind).toBe('shape');
});
it('offers group for a loose selection and ungroup for a group, named in the inspector head', () => {
store().select(['g1', 'g2']);
const spread = must(store().doc).spreads[0];
expect(selectionTitle(spread, ['g1', 'g2'], t)).toBe('Pair');
expect(selectionTitle(spread, ['a', 'g1'], t)).toBe('journey.studio.multiple');
render(<StudioArrangeSection spreadIndex={0} t={t} />);
expect(screen.getByText('journey.studio.layers.groupHint')).toBeInTheDocument();
fireEvent.click(screen.getByLabelText('journey.studio.layers.ungroup'));
expect(el('g1').group).toBeUndefined();
});
it('draws nothing without a selection', () => {
const { container } = render(<StudioArrangeSection spreadIndex={0} t={t} />);
expect(container.innerHTML).toBe('');
});
});
@@ -1,432 +0,0 @@
import type { BookElement } from '@trek/shared';
import { isWholeGroup } from '@trek/shared';
import {
Award,
ChartColumn,
ChevronRight,
Eye,
EyeOff,
Globe,
GripVertical,
Group,
ImageIcon,
List,
Lock,
MapIcon,
Shapes,
Sparkles,
Square,
Type,
Ungroup,
Unlock,
type LucideIcon,
} from 'lucide-react';
import { useRef, useState } from 'react';
import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-layers.css';
import { Tooltip } from '../shared/Tooltip';
import { PanelHead } from './StudioPanelHead';
import { dropOn, layerLabel, layerRows, rowIds, type LayerDrop, type LayerRow } from './studioLayerRows';
import { useStudioShortcut } from './studioShortcut';
/**
* The layers of the open page, front to back (journey-studio WP6).
*
* One row per element and one per group, its members indented under it. A row
* selects what it stands for; a member's row selects the member alone, inside
* its group, the way a double-click on the sheet does. Rows drag to restack:
* the upper half of a row puts what is dragged in front of it, the lower half
* behind it, and a member's row takes it into that group. Lock and visibility
* sit at the end of every row, always shown when they are on so a locked or
* hidden layer can be told at a glance; a hidden layer is dimmed, not removed,
* since this panel is the way back to it. Every change is one undo step.
*/
const KIND_ICONS: Record<string, LucideIcon> = {
photo: ImageIcon,
text: Type,
shape: Square,
icon: Sparkles,
map: MapIcon,
stats: ChartColumn,
countries: Globe,
badge: Award,
list: List,
};
/** How far, in pixels, a press on a row travels before it is a drag rather than a click. */
const DRAG_PX = 4;
interface Drag {
ids: string[];
startY: number;
active: boolean;
drop: LayerDrop | null;
/** The row the pointer is over and which half, for the insertion line. */
overKey: string | null;
upper: boolean;
}
type T = (k: string, params?: Record<string, string | number>) => string;
export function StudioLayersPanel({ t }: { t: T }) {
const spreadIndex = useStudioStore((s) => s.activeSpread);
const spread = useStudioStore((s) => s.doc?.spreads[s.activeSpread]);
const selection = useStudioStore((s) => s.selection);
const enteredGroup = useStudioStore((s) => s.enteredGroup);
const readOnly = useStudioStore((s) => s.readOnly);
const pick = useStudioStore((s) => s.pick);
const selectGroup = useStudioStore((s) => s.selectGroup);
const enterGroup = useStudioStore((s) => s.enterGroup);
const restack = useStudioStore((s) => s.restack);
const setHidden = useStudioStore((s) => s.setHidden);
const setLocked = useStudioStore((s) => s.setLocked);
const renameLayer = useStudioStore((s) => s.renameLayer);
const group = useStudioStore((s) => s.group);
const ungroup = useStudioStore((s) => s.ungroup);
const keys = useStudioShortcut();
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const [renaming, setRenaming] = useState<string | null>(null);
const [drag, setDrag] = useState<Drag | null>(null);
const list = useRef<HTMLDivElement>(null);
/** A drag ends in a click on the row it started from; that click is not a choice. */
const justDragged = useRef(false);
if (!spread) return null;
const rows = layerRows(spread, collapsed);
const selected = new Set(selection);
const movable = spread.elements.filter((e) => selected.has(e.id) && !e.locked);
const canGroup =
movable.length >= 2 &&
!isWholeGroup(
spread,
movable.map((e) => e.id)
);
const canUngroup = spread.elements.some((e) => selected.has(e.id) && e.group);
const toggleCollapsed = (groupId: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
return next;
});
const choose = (row: LayerRow, additive: boolean) => {
if (justDragged.current) {
justDragged.current = false;
return;
}
if (row.type === 'group') selectGroup(spreadIndex, row.groupId, additive);
else if (row.inGroup && !additive) enterGroup(spreadIndex, row.el.id);
else pick(spreadIndex, row.el.id, additive);
};
/** What a drag from this row carries: the selection when the row is in it, else what the row stands for. */
const dragIds = (row: LayerRow) => {
const own = rowIds(row);
return own.every((id) => selected.has(id))
? spread.elements.filter((e) => selected.has(e.id)).map((e) => e.id)
: own;
};
const rowAt = (clientY: number): { row: LayerRow; upper: boolean } | null => {
const nodes = list.current?.querySelectorAll<HTMLElement>('[data-layer-key]') ?? [];
for (const node of nodes) {
const r = node.getBoundingClientRect();
if (clientY < r.top || clientY > r.bottom) continue;
const row = rows.find((x) => x.key === node.dataset.layerKey);
if (row) return { row, upper: clientY < r.top + r.height / 2 };
}
return null;
};
const onPointerDown = (e: React.PointerEvent, row: LayerRow) => {
if (e.button !== 0 || readOnly) return;
if ((e.target as HTMLElement).closest('button, input')) return;
setDrag({ ids: dragIds(row), startY: e.clientY, active: false, drop: null, overKey: null, upper: true });
};
const onPointerMove = (e: React.PointerEvent) => {
if (!drag) return;
if (!drag.active && Math.abs(e.clientY - drag.startY) < DRAG_PX) return;
if (!drag.active) {
try {
list.current?.setPointerCapture?.(e.pointerId);
} catch {
// A pointer that is gone already cannot be captured; the drag still follows the list.
}
}
const at = rowAt(e.clientY);
const over = at && !rowIds(at.row).some((id) => drag.ids.includes(id)) ? at : null;
setDrag({
...drag,
active: true,
drop: over ? dropOn(over.row, over.upper) : null,
overKey: over?.row.key ?? null,
upper: over?.upper ?? true,
});
};
const onPointerUp = (e: React.PointerEvent) => {
if (!drag) return;
try {
list.current?.releasePointerCapture?.(e.pointerId);
} catch {
// Released already, or never taken: a click that did not move.
}
justDragged.current = drag.active;
if (drag.active && drag.drop) restack(spreadIndex, drag.ids, drag.drop.anchorId, drag.drop.where, drag.drop.join);
setDrag(null);
};
const commitName = (row: LayerRow, value: string) => {
setRenaming(null);
const current = row.type === 'group' ? (row.name ?? '') : (row.el.name ?? '');
if (value.trim() === current) return;
renameLayer(spreadIndex, row.type === 'group' ? { groupId: row.groupId } : { elementId: row.el.id }, value);
};
return (
<>
<PanelHead label={t('journey.studio.layers')} count={spread.elements.length} />
<div className="st-panel-scroll st-layers-scroll">
{rows.length === 0 ? (
<p className="st-hint">{t('journey.studio.layers.empty')}</p>
) : (
<>
<p className="st-hint st-layers-hint">{t('journey.studio.layers.hint')}</p>
<div
ref={list}
className={`st-layers ${drag?.active ? 'is-dragging' : ''}`}
role="tree"
aria-label={t('journey.studio.layers')}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={() => setDrag(null)}
>
{rows.map((row) => (
<LayerRowView
key={row.key}
row={row}
t={t}
selected={rowIds(row).every((id) => selected.has(id))}
inside={row.type === 'element' && row.inGroup && row.el.group === enteredGroup}
collapsed={row.type === 'group' && collapsed.has(row.groupId)}
renaming={renaming === row.key}
dragged={!!drag?.active && rowIds(row).every((id) => drag.ids.includes(id))}
dropAt={drag?.active && drag.overKey === row.key ? (drag.upper ? 'above' : 'below') : null}
readOnly={readOnly}
onPointerDown={(e) => onPointerDown(e, row)}
onChoose={(additive) => choose(row, additive)}
onToggleCollapsed={() => row.type === 'group' && toggleCollapsed(row.groupId)}
onRename={() => !readOnly && setRenaming(row.key)}
onCommitName={(v) => commitName(row, v)}
onCancelName={() => setRenaming(null)}
onLock={(locked) => setLocked(spreadIndex, rowIds(row), locked)}
onHide={(hidden) => setHidden(spreadIndex, rowIds(row), hidden)}
/>
))}
</div>
</>
)}
</div>
{!readOnly && rows.length > 0 && (
<div className="st-layers-foot">
<Tooltip label={t('journey.studio.layers.groupAction')} shortcut={keys('G')} placement="top">
<button type="button" className="st-chip" disabled={!canGroup} onClick={() => group(spreadIndex)}>
<Group size={14} />
{t('journey.studio.layers.groupAction')}
</button>
</Tooltip>
<Tooltip label={t('journey.studio.layers.ungroup')} shortcut={keys('⇧', 'G')} placement="top">
<button type="button" className="st-chip" disabled={!canUngroup} onClick={() => ungroup(spreadIndex)}>
<Ungroup size={14} />
{t('journey.studio.layers.ungroup')}
</button>
</Tooltip>
</div>
)}
</>
);
}
interface RowProps {
row: LayerRow;
t: T;
selected: boolean;
inside: boolean;
collapsed: boolean;
renaming: boolean;
dragged: boolean;
dropAt: 'above' | 'below' | null;
readOnly: boolean;
onPointerDown: (e: React.PointerEvent) => void;
onChoose: (additive: boolean) => void;
onToggleCollapsed: () => void;
onRename: () => void;
onCommitName: (value: string) => void;
onCancelName: () => void;
onLock: (locked: boolean) => void;
onHide: (hidden: boolean) => void;
}
function rowState(row: LayerRow): { locked: boolean; hidden: boolean } {
const els: BookElement[] = row.type === 'group' ? row.members : [row.el];
return { locked: els.every((e) => e.locked), hidden: els.every((e) => e.hidden) };
}
/** The row's classes: what it is, whether it is picked, hidden, dragged, or where a drop would land. */
function rowClasses(p: RowProps, hidden: boolean): string {
const { row } = p;
return [
'st-layer',
row.type === 'group' ? 'is-group' : '',
row.type === 'element' && row.inGroup ? 'is-member' : '',
p.selected ? 'is-on' : '',
p.inside ? 'is-inside' : '',
hidden ? 'is-hidden' : '',
p.dragged ? 'is-dragged' : '',
p.dropAt ? `is-drop-${p.dropAt}` : '',
]
.filter(Boolean)
.join(' ');
}
/** What the row says it is, and whether that is a name somebody gave it. */
function rowLabel(row: LayerRow, t: T): { label: string; named: boolean } {
if (row.type === 'group') return { label: row.name || t('journey.studio.layers.group'), named: !!row.name };
return { label: layerLabel(row.el, t(`journey.studio.kind.${row.el.kind}`)), named: !!row.el.name };
}
function LayerRowView(p: RowProps) {
const { row, t } = p;
const { locked, hidden } = rowState(p.row);
const Icon = row.type === 'group' ? Group : (KIND_ICONS[row.el.kind] ?? Shapes);
const { label, named } = rowLabel(row, t);
return (
<div
className={rowClasses(p, hidden)}
data-layer-key={row.key}
role="treeitem"
aria-selected={p.selected}
aria-expanded={row.type === 'group' ? !p.collapsed : undefined}
onPointerDown={p.onPointerDown}
onClick={(e) => p.onChoose(e.shiftKey || e.metaKey || e.ctrlKey)}
>
{!p.readOnly && <GripVertical size={12} className="st-layer-grip" aria-hidden />}
{row.type === 'group' && <RowCaret collapsed={p.collapsed} onToggle={p.onToggleCollapsed} t={t} />}
<Icon size={13} className="st-layer-icon" aria-hidden />
<RowName
label={label}
named={named}
renaming={p.renaming}
t={t}
onRename={p.onRename}
onCommit={p.onCommitName}
onCancel={p.onCancelName}
/>
{row.type === 'group' && <span className="st-layer-count">{row.members.length}</span>}
{!p.readOnly && <RowToggles locked={locked} hidden={hidden} t={t} onLock={p.onLock} onHide={p.onHide} />}
</div>
);
}
/** A click that does something on its own and must not also select the row. */
const own = (fn: () => void) => (e: React.MouseEvent) => {
e.stopPropagation();
fn();
};
function RowCaret({ collapsed, onToggle, t }: { collapsed: boolean; onToggle: () => void; t: T }) {
return (
<button
type="button"
className={`st-layer-caret ${collapsed ? '' : 'is-open'}`}
onClick={own(onToggle)}
aria-label={t(collapsed ? 'journey.studio.layers.expand' : 'journey.studio.layers.collapse')}
>
<ChevronRight size={12} />
</button>
);
}
function RowName(p: {
label: string;
named: boolean;
renaming: boolean;
t: T;
onRename: () => void;
onCommit: (value: string) => void;
onCancel: () => void;
}) {
if (!p.renaming) {
return (
<span
className={`st-layer-name ${p.named ? '' : 'is-auto'}`}
title={p.t('journey.studio.layers.rename')}
onDoubleClick={own(p.onRename)}
>
{p.label}
</span>
);
}
return (
<input
className="st-input st-layer-input"
autoFocus
defaultValue={p.named ? p.label : ''}
placeholder={p.label}
maxLength={60}
aria-label={p.t('journey.studio.layers.rename')}
onClick={(e) => e.stopPropagation()}
onBlur={(e) => p.onCommit(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key !== 'Escape') return;
// Escape here leaves the name as it was, and must not close Studio.
e.stopPropagation();
e.preventDefault();
p.onCancel();
}}
/>
);
}
function RowToggles(p: {
locked: boolean;
hidden: boolean;
t: T;
onLock: (locked: boolean) => void;
onHide: (hidden: boolean) => void;
}) {
const lockLabel = p.t(p.locked ? 'journey.studio.unlock' : 'journey.studio.lock');
const hideLabel = p.t(p.hidden ? 'journey.studio.layers.show' : 'journey.studio.layers.hide');
return (
<span className="st-layer-acts">
<button
type="button"
className={`st-act ${p.locked ? 'is-on is-kept' : ''}`}
onClick={own(() => p.onLock(!p.locked))}
title={lockLabel}
aria-label={lockLabel}
aria-pressed={p.locked}
>
{p.locked ? <Lock size={12} /> : <Unlock size={12} />}
</button>
<button
type="button"
className={`st-act ${p.hidden ? 'is-on is-kept' : ''}`}
onClick={own(() => p.onHide(!p.hidden))}
title={hideLabel}
aria-label={hideLabel}
aria-pressed={p.hidden}
>
{p.hidden ? <EyeOff size={12} /> : <Eye size={12} />}
</button>
</span>
);
}
@@ -1,212 +0,0 @@
import {
BOOK_LINE_CAPS,
BOOK_LINE_ORNAMENTS,
type BookLineCap,
type BookLineElement,
type BookLineOrnament,
bookLineStyled,
} from '@trek/shared';
import { ArrowLeftRight } from 'lucide-react';
import { useStudioStore } from '../../store/studioStore';
import { LinePaths } from './LineView';
import { Choice, Line, NumField, Section } from './StudioControls';
import { Swatches } from './StudioSwatches';
import { lineGeometry } from './lineRender';
/**
* The inspector of a drawn line: its colour and weight, dashes, the course it
* takes, a wave or a zigzag, the hand-drawn wobble, what sits on each end and
* in the middle.
*
* Every change goes through `bookLineStyled`, the book engine's own restyle,
* so a wider stroke or a new head refits the frame exactly as the assistant's
* `style` does, and the ends stay where they were drawn.
*/
type Look = Partial<Omit<BookLineElement, 'id' | 'kind' | 'frame' | 'points'>>;
type GlyphLook = Parameters<typeof lineGeometry>[0];
/** A tiny level line for the pickers: a plain rule, with what the picker is choosing set on it. */
function MiniLine({ over }: { over: Partial<GlyphLook> }) {
const el: GlyphLook = {
points: [
{ x: 0.08, y: 0.5 },
{ x: 0.92, y: 0.5 },
],
curve: 'straight',
strokeWidth: 1.4,
dash: 'solid',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'none',
...over,
};
const g = lineGeometry(el, 28, 12, 'glyph');
return (
<svg viewBox="0 0 28 12" width="28" height="12" aria-hidden style={{ overflow: 'visible' }}>
<LinePaths g={g} color="currentColor" width={el.strokeWidth} />
</svg>
);
}
/** A tiny line with this end on it, for the end pickers. */
const CapGlyph = ({ cap, start }: { cap: BookLineCap; start: boolean }) => (
<MiniLine over={start ? { startCap: cap } : { endCap: cap }} />
);
/** A tiny rule with this ornament in its middle. */
const OrnamentGlyph = ({ ornament }: { ornament: BookLineOrnament }) => (
<MiniLine over={{ ornament, strokeWidth: 0.7 }} />
);
export function LineInspector({
el,
spreadIndex,
t,
}: {
el: BookLineElement;
spreadIndex: number;
t: (k: string) => string;
}) {
const commit = useStudioStore((s) => s.commit);
const selection = useStudioStore((s) => s.selection);
/** Restyle every selected line, each refitted around its own points. */
const set = (look: Look) =>
commit((d) => ({
...d,
spreads: d.spreads.map((sp, i) =>
i !== spreadIndex
? sp
: {
...sp,
elements: sp.elements.map((e) =>
e.kind === 'line' && selection.includes(e.id) && !e.locked ? bookLineStyled(e, look) : e
),
}
),
}));
/** The line turned round: its points in the other order and its two ends swapped with them. */
const swapEnds = () =>
commit((d) => ({
...d,
spreads: d.spreads.map((sp, i) =>
i !== spreadIndex
? sp
: {
...sp,
elements: sp.elements.map((e) =>
e.kind === 'line' && selection.includes(e.id) && !e.locked
? { ...e, points: [...e.points].reverse(), startCap: e.endCap, endCap: e.startCap }
: e
),
}
),
}));
const caps = (start: boolean) =>
BOOK_LINE_CAPS.map((c) => ({
value: c,
label: <CapGlyph cap={c} start={start} />,
title: t(`journey.studio.cap.${c}`),
}));
return (
<>
<Section label={t('journey.studio.kind.line')}>
<Line label={t('journey.studio.stroke')} wide>
<Swatches value={el.stroke} onPick={(c) => set({ stroke: c })} />
</Line>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.strokeWidth')}
value={el.strokeWidth}
min={0.05}
max={20}
step={0.05}
unit="mm"
onChange={(v) => set({ strokeWidth: v })}
/>
<NumField
label={t('journey.studio.wobble')}
value={Math.round(el.wobble * 100)}
min={0}
max={100}
step={10}
unit="%"
onChange={(v) => set({ wobble: Math.min(1, Math.max(0, v / 100)) })}
/>
</div>
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.strokeStyle')} wide>
<Choice
value={el.dash}
options={[
{ value: 'solid' as const, label: t('journey.studio.strokeSolid') },
{ value: 'dashed' as const, label: t('journey.studio.strokeDashed') },
{ value: 'dotted' as const, label: t('journey.studio.strokeDotted') },
]}
onPick={(dash) => set({ dash })}
/>
</Line>
<Line label={t('journey.studio.lineCourseTitle')} wide>
<Choice
value={el.curve}
options={(['straight', 'smooth'] as const).map((value) => ({
value,
label: t(`journey.studio.lineCourse.${value}`),
}))}
onPick={(curve) => set({ curve })}
/>
</Line>
<Line label={t('journey.studio.lineBodyTitle')} wide>
<Choice
value={el.squiggle}
options={(['none', 'wave', 'zigzag'] as const).map((value) => ({
value,
label: t(`journey.studio.lineBody.${value}`),
}))}
onPick={(squiggle) => set({ squiggle })}
/>
</Line>
</div>
<p className="st-hint" style={{ paddingTop: 8 }}>
{t('journey.studio.linePointsHint')}
</p>
</Section>
<Section label={t('journey.studio.lineEnds')}>
<Line label={t('journey.studio.lineStart')} wide>
<Choice value={el.startCap} options={caps(true)} onPick={(startCap) => set({ startCap })} />
</Line>
<Line label={t('journey.studio.lineEnd')} wide>
<Choice value={el.endCap} options={caps(false)} onPick={(endCap) => set({ endCap })} />
</Line>
<div className="st-row" style={{ marginTop: 6 }}>
<button type="button" className="st-chip" onClick={swapEnds}>
<ArrowLeftRight size={13} />
{t('journey.studio.swapEnds')}
</button>
</div>
</Section>
<Section label={t('journey.studio.ornamentTitle')} defaultOpen={el.ornament !== 'none'}>
<Choice
value={el.ornament}
options={BOOK_LINE_ORNAMENTS.map((o) => ({
value: o,
label: <OrnamentGlyph ornament={o} />,
title: t(`journey.studio.ornament.${o}`),
}))}
onPick={(ornament) => set({ ornament })}
ariaLabel={t('journey.studio.ornamentTitle')}
/>
</Section>
</>
);
}
@@ -1,86 +0,0 @@
import { type BookElement, type BookPageSetup, bookElementId, bookNewLine } from '@trek/shared';
import { LinePaths } from './LineView';
import { DIVIDER_PRESETS, LINE_PRESETS, type LinePreset as Preset, presetPoints } from './linePresets';
import { lineGeometry } from './lineRender';
/**
* The drawn lines and dividers of the Elements panel: a straight rule, arrows,
* dashes and dots, a curve, a wave, a zigzag, a line drawn by hand, a dotted
* route, and the rules a title sits between.
*
* Each tile is drawn by the same geometry the page uses, so the tile is the
* line you get. They arrive half a page wide on the right-hand page, where the
* assistant's `add` puts them too, and their ends are moved on the canvas.
*/
/** A line as the page draws it, as a tile. */
function LineGlyph({ preset }: { preset: Preset }) {
const W = 40;
const H = 24;
const el = bookNewLine(
{
...preset.look,
strokeWidth: Math.max(1.2, (preset.look.strokeWidth ?? 0.5) * 2.4),
points: presetPoints(preset, 4, H / 2, W - 8),
},
{ id: preset.id, frame: null, inner: false, page: { pageWidth: W, pageHeight: H } as BookPageSetup }
);
const g = lineGeometry(el, el.frame.w, el.frame.h, preset.id);
return (
<svg viewBox={`${-el.frame.x} ${-el.frame.y} ${W} ${H}`} width="100%" height="100%" aria-hidden>
<LinePaths g={g} color="currentColor" width={el.strokeWidth} />
</svg>
);
}
export function StudioLineShelf({
page,
single,
onAdd,
t,
}: {
page: BookPageSetup;
single: boolean;
onAdd: (el: BookElement) => void;
t: (k: string) => string;
}) {
const add = (preset: Preset) => {
const origin = single ? 0 : page.pageWidth;
const w = page.pageWidth * (preset.id.startsWith('divider') ? 0.4 : 0.5);
const x = origin + (page.pageWidth - w) / 2;
const points = presetPoints(preset, x, page.pageHeight / 2, w);
onAdd(bookNewLine({ ...preset.look, points }, { id: bookElementId('l'), frame: null, inner: !single, page }));
};
const tiles = (list: readonly Preset[]) => (
<div className="st-shape-grid">
{list.map((p) => {
const label = t(`journey.studio.linePreset.${p.id}`);
return (
<button
type="button"
key={p.id}
className="st-shape-btn is-glyph"
onClick={() => add(p)}
title={label}
aria-label={label}
>
<LineGlyph preset={p} />
</button>
);
})}
</div>
);
return (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.drawnLines')}</div>
{tiles(LINE_PRESETS)}
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.dividers')}</div>
{tiles(DIVIDER_PRESETS)}
</div>
</>
);
}
@@ -1,371 +0,0 @@
import {
PATTERN_SCALE,
RADIAL_RADIUS,
stopAlpha,
type BookGradientPaint,
type BookPaint,
type BookPattern,
type BookPatternPaint,
} from '@trek/shared';
import { ArrowLeftRight, Ban, Trash2 } from 'lucide-react';
import { useRef, useState } from 'react';
import '../../styles/studio-paint.css';
import { NumField } from './StudioControls';
import type { StudioT } from './StudioSidebar';
import { Swatches } from './StudioSwatches';
import { AngleDial } from './paint/AngleDial';
import { GradientStopBar } from './paint/GradientStopBar';
import { PatternSwatches } from './paint/PatternSwatches';
import { RadialPad } from './paint/RadialPad';
import {
PATTERN_ANGLE,
clampRadius,
clampScale,
cssPaint,
gradientAs,
kindOf,
normAngle,
paintAs,
removeStop,
reverseStops,
updateStop,
type PaintKind,
} from './paint/paintEdit';
import type { PaintChannel } from './paint/usePaintChannel';
/**
* Paint, picked: none, one colour, a gradient or a pattern.
*
* ── How it is laid out ───────────────────────────────────────────────────
*
* One row of four tabs says what kind of paint it is, each with a swatch of
* what choosing it gives, and below it only the controls that kind has. The
* tab is the question people ask first ("flat or a gradient?"), so it is a
* row they can read at a glance rather than a menu.
*
* Switching kind keeps the colour: a solid becomes a gradient from that
* colour to a lighter partner, a gradient becomes a pattern in its first
* colour, and coming back to a kind already visited in this session brings
* it back as it was left, so trying a pattern costs nothing.
*
* Every drag (a stop, the dial, the centre, a slider) writes live and is one
* undo step when it is let go, through the channel the caller hands in.
*/
const KINDS: readonly PaintKind[] = ['none', 'solid', 'gradient', 'pattern'];
/** A kind's name on its tab. */
function kindLabel(k: Exclude<PaintKind, 'none'>, t: StudioT): string {
if (k === 'solid') return t('journey.studio.paint.solid');
return k === 'gradient' ? t('journey.studio.paint.gradient') : t('journey.studio.paint.pattern');
}
export function StudioPaintPicker({
value,
channel,
t,
noneLabel,
}: {
value: BookPaint | null;
channel: PaintChannel;
t: StudioT;
/** What "no paint" is called here: no fill on a shape, the paper on a page. */
noneLabel: string;
}) {
const kind = kindOf(value);
// The last paint of each kind shown here, so a kind visited again comes back as it was left.
const [memory, setMemory] = useState<Partial<Record<PaintKind, BookPaint>>>({});
const [stop, setStop] = useState(0);
const write = (paint: BookPaint | null, done = true) => {
const k = kindOf(paint);
if (paint && k !== 'none') setMemory((m) => ({ ...m, [k]: paint }));
if (done) channel.commit(paint);
else channel.live(paint);
};
const choose = (next: PaintKind) => {
if (next === kind) return;
const kept = value ? { ...memory, [kind]: value } : memory;
if (value) setMemory((m) => ({ ...m, [kind]: value }));
setStop(0);
write(paintAs(next, value, kept));
};
const tabPreview = (k: PaintKind) =>
k === 'none' ? undefined : cssPaint(k === kind ? value : paintAs(k, value, memory));
return (
<div className="st-paint">
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
{KINDS.map((k) => (
<button
type="button"
key={k}
role="tab"
aria-selected={k === kind}
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
onClick={() => choose(k)}
>
<span
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
aria-hidden
>
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
</span>
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
</button>
))}
</div>
{value?.type === 'solid' && (
<Swatches
value={value.color}
onPick={(color) => write({ ...value, color })}
alpha={stopAlpha(value)}
onAlpha={(alpha, done) => write(withAlpha(value, alpha), done)}
/>
)}
{(value?.type === 'linear' || value?.type === 'radial') && (
<GradientControls
paint={value}
stop={Math.min(stop, value.stops.length - 1)}
onStop={setStop}
write={write}
t={t}
/>
)}
{value?.type === 'pattern' && <PatternControls paint={value} write={write} t={t} />}
</div>
);
}
const withAlpha = <T extends { alpha?: number }>(o: T, alpha: number): T => {
const { alpha: _old, ...rest } = o;
return (alpha >= 1 ? rest : { ...rest, alpha: Math.round(alpha * 100) / 100 }) as T;
};
type Write = (paint: BookPaint | null, done?: boolean) => void;
function GradientControls({
paint,
stop,
onStop,
write,
t,
}: {
paint: BookGradientPaint;
stop: number;
onStop: (i: number) => void;
write: Write;
t: StudioT;
}) {
const current = paint.stops[stop];
const canRemove = paint.stops.length > 2;
return (
<div className="st-paint-body">
<div className="st-paint-row">
<div className="st-seg" role="radiogroup" aria-label={t('journey.studio.paint.gradient')}>
{(['linear', 'radial'] as const).map((type) => (
<button
type="button"
key={type}
role="radio"
aria-checked={paint.type === type}
className={`st-seg-item ${paint.type === type ? 'is-on' : ''}`}
onClick={() => write(gradientAs(paint, type))}
>
{type === 'linear' ? t('journey.studio.paint.linear') : t('journey.studio.paint.radial')}
</button>
))}
</div>
<button
type="button"
className="st-chip is-icon"
title={t('journey.studio.paint.reverse')}
aria-label={t('journey.studio.paint.reverse')}
onClick={() => write(reverseStops(paint))}
>
<ArrowLeftRight size={13} />
</button>
</div>
<GradientStopBar
paint={paint}
selected={stop}
onSelect={onStop}
onChange={(p, done) => write(p, done)}
labels={{
bar: t('journey.studio.paint.addStopHint'),
stop: (pos) => t('journey.studio.paint.stopAt', { pos }),
}}
/>
{current && (
<div className="st-paint-stop">
<div className="st-paint-row">
<span className="st-paint-caption">
{t('journey.studio.paint.stopAt', { pos: Math.round(current.at * 100) })}
</span>
<button
type="button"
className="st-chip is-icon is-danger"
disabled={!canRemove}
title={t('journey.studio.paint.removeStop')}
aria-label={t('journey.studio.paint.removeStop')}
onClick={() => {
onStop(Math.max(0, stop - 1));
write(removeStop(paint, stop));
}}
>
<Trash2 size={13} />
</button>
</div>
<Swatches
value={current.color}
onPick={(color) => write(updateStop(paint, stop, { color }))}
alpha={stopAlpha(current)}
onAlpha={(alpha, done) => write(updateStop(paint, stop, { alpha }), done)}
/>
</div>
)}
{paint.type === 'linear' ? (
<div className="st-paint-row is-dial">
<AngleDial
value={paint.angle}
label={t('journey.studio.paint.angle')}
onChange={(angle, done) => write({ ...paint, angle }, done)}
/>
<NumField
label={t('journey.studio.paint.angle')}
value={normAngle(paint.angle)}
min={0}
max={359}
step={1}
unit="°"
onChange={(angle) => write({ ...paint, angle: normAngle(angle) })}
/>
</div>
) : (
<div className="st-paint-row is-dial">
<RadialPad paint={paint} label={t('journey.studio.paint.centre')} onChange={(p, done) => write(p, done)} />
<Slider
label={t('journey.studio.paint.radius')}
value={paint.r}
min={RADIAL_RADIUS.min}
max={RADIAL_RADIUS.max}
shown={`${Math.round(paint.r * 100)}%`}
onChange={(r, done) => write({ ...paint, r: clampRadius(r) }, done)}
/>
</div>
)}
</div>
);
}
function PatternControls({ paint, write, t }: { paint: BookPatternPaint; write: Write; t: StudioT }) {
const pick = (pattern: BookPattern) => write({ ...paint, pattern, angle: PATTERN_ANGLE[pattern] ?? paint.angle });
return (
<div className="st-paint-body">
<PatternSwatches value={paint} onPick={pick} label={(p) => t(`journey.studio.pattern.${p}`)} />
<div className="st-paint-field">
<span className="st-paint-caption">{t('journey.studio.paint.ink')}</span>
<Swatches value={paint.fg} onPick={(fg) => write({ ...paint, fg })} />
</div>
<div className="st-paint-field">
<div className="st-paint-row">
<span className="st-paint-caption">{t('journey.studio.paint.ground')}</span>
<button
type="button"
role="switch"
aria-checked={paint.bg === null}
className={`st-chip is-pick ${paint.bg === null ? 'is-on' : ''}`}
onClick={() => write({ ...paint, bg: paint.bg === null ? '#ffffff' : null })}
>
{t('journey.studio.paint.seeThrough')}
</button>
</div>
{paint.bg !== null && <Swatches value={paint.bg} onPick={(bg) => write({ ...paint, bg })} />}
</div>
<div className="st-paint-row is-dial">
<AngleDial
value={paint.angle}
label={t('journey.studio.paint.angle')}
onChange={(angle, done) => write({ ...paint, angle }, done)}
/>
<Slider
label={t('journey.studio.paint.scale')}
value={paint.scale}
min={PATTERN_SCALE.min}
max={PATTERN_SCALE.max}
shown={`${Math.round(paint.scale * 100)}%`}
log
onChange={(scale, done) => write({ ...paint, scale: clampScale(scale) }, done)}
/>
</div>
</div>
);
}
/** A labelled slider with its value beside it; `done` as for the dial. */
function Slider({
label,
value,
min,
max,
shown,
onChange,
log = false,
}: {
label: string;
value: number;
min: number;
max: number;
shown: string;
onChange: (v: number, done: boolean) => void;
/** Even steps by ratio rather than by difference: a pattern's scale doubles, it does not add. */
log?: boolean;
}) {
const steps = 100;
const toValue = (raw: string) => {
const t = Number(raw) / steps;
return log ? min * (max / min) ** t : min + (max - min) * t;
};
const pos = Math.round((log ? Math.log(value / min) / Math.log(max / min) : (value - min) / (max - min)) * steps);
// What the slider was last moved to: a controlled input may still show the old value when it is let go.
const moved = useRef(value);
return (
<label className="st-paint-slider">
<span className="st-paint-slider-head">
<span className="st-paint-caption">{label}</span>
<span className="st-paint-value">{shown}</span>
</span>
<input
type="range"
min={0}
max={steps}
step={1}
value={pos}
onPointerDown={() => {
moved.current = value;
}}
onKeyDown={() => {
moved.current = value;
}}
onChange={(e) => {
moved.current = toValue(e.target.value);
onChange(moved.current, false);
}}
onPointerUp={() => onChange(moved.current, true)}
onKeyUp={() => onChange(moved.current, true)}
/>
</label>
);
}
@@ -1,60 +0,0 @@
import type { BookFrame } from '@trek/shared';
import { HANDLES, HANDLE_POS, type HandleId } from './studioGestures';
/**
* The selection chrome the sheet draws above the page for more than one
* element: one box with the eight resize handles around all of them, dashed
* when it is exactly one group, and the marquee while one is being drawn.
* Screen pixels, like every outline on the sheet, so a line stays a hairline
* at any zoom. The sheet is laid out in millimetres from its left edge in
* every reading direction, which is why these sit at physical offsets.
*/
/** The rectangle of a frame on screen. */
const onScreen = (f: BookFrame, scaled: number) => ({
left: f.x * scaled, // rtl-lint-disable: the sheet is measured from its left edge
top: f.y * scaled,
width: f.w * scaled,
height: f.h * scaled,
});
/** The eight resize handles of a box, corners round and edges as short bars. */
export function ResizeHandles({ onResize }: { onResize: (e: React.PointerEvent, handle: HandleId) => void }) {
return (
<>
{HANDLES.map((h) => {
const kind = h.length === 2 ? 'is-corner' : h === 'n' || h === 's' ? 'is-h' : 'is-v';
const at = HANDLE_POS[h];
const place = { left: at.x, top: at.y, cursor: at.cursor }; // rtl-lint-disable: a corner of the box, not a side of the text
return <span key={h} className={`st-handle ${kind}`} style={place} onPointerDown={(e) => onResize(e, h)} />;
})}
</>
);
}
/** One box around a multi-selection, with handles that resize all of it (Shift keeps the proportions). */
export function SelectionBox({
box,
scaled,
asGroup,
handles,
onResize,
}: {
box: BookFrame;
scaled: number;
asGroup: boolean;
handles: boolean;
onResize: (e: React.PointerEvent, handle: HandleId) => void;
}) {
return (
<div className={`st-select-box ${asGroup ? 'is-group' : ''}`} style={onScreen(box, scaled)}>
{handles && <ResizeHandles onResize={onResize} />}
</div>
);
}
/** The marquee being drawn. */
export function MarqueeRect({ rect, scaled }: { rect: BookFrame; scaled: number }) {
return <div className="st-marquee" style={onScreen(rect, scaled)} />;
}
@@ -1,93 +0,0 @@
import {
shapePaint,
withShapePaint,
withThemeLinks,
type BookElement,
type BookShapeElement,
type BookTheme,
} from '@trek/shared';
import { usePaintChannel } from './paint/usePaintChannel';
import { Line, Section } from './StudioControls';
import { StudioPaintPicker } from './StudioPaintPicker';
import type { StudioT } from './StudioSidebar';
import { Swatches } from './StudioSwatches';
import { StudioThemeLink } from './theme/StudioThemeLink';
/** The outline a shape is given when its fill goes and it has none, so it never turns invisible. */
const OUTLINE = '#141414';
/**
* A shape's colours: its fill as a paint, and its outline.
*
* The fill goes to every selected shape at once, the way the rest of the
* inspector's properties do. Taking the fill away from a shape with no
* outline gives it one, as the old fill switch did: a shape with neither is
* a shape nobody can see or find again.
*/
export function StudioShapePaint({
el,
spreadIndex,
selection,
onStroke,
theme,
set,
t,
}: {
el: BookShapeElement;
spreadIndex: number;
selection: readonly string[];
onStroke: (color: string) => void;
/** The book's theme, for the theme colours under each row; none without one. */
theme?: BookTheme;
/** The inspector's write, for linking a colour to the theme. */
set?: (patch: Partial<BookShapeElement>) => void;
t: StudioT;
}) {
const channel = usePaintChannel((paint) => (doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) =>
i !== spreadIndex
? sp
: {
...sp,
elements: sp.elements.map((e): BookElement => {
if (e.kind !== 'shape' || !selection.includes(e.id)) return e;
// A paint picked by hand stops the fill following the theme.
const painted = withThemeLinks(withShapePaint(e, paint), { paint }, doc.theme);
return paint === null && !e.stroke
? { ...painted, stroke: OUTLINE, strokeWidth: e.strokeWidth || 0.5 }
: painted;
}),
}
),
}));
return (
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.fill')} wide>
<StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} />
{set && (
<StudioThemeLink
theme={theme}
token={el.token}
t={t}
onLink={(token, fill) => set({ fill, token, paint: undefined })}
onUnlink={() => set({ token: undefined })}
/>
)}
</Line>
<Line label={t('journey.studio.stroke')} wide>
<Swatches value={el.stroke ?? OUTLINE} onPick={onStroke} />
{set && (
<StudioThemeLink
theme={theme}
token={el.strokeToken}
t={t}
onLink={(strokeToken, stroke) => set({ stroke, strokeToken, strokeWidth: el.strokeWidth || 0.5 })}
onUnlink={() => set({ strokeToken: undefined })}
/>
)}
</Line>
</Section>
);
}

Some files were not shown because too many files have changed in this diff Show More