mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ae607f54 |
@@ -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 |
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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_:-]+)+$/;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }] })),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user