mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26a6147e8d | |||
| a7b53988ff | |||
| 3f3e2f0bcf | |||
| f956ab07f3 | |||
| 736594696e | |||
| 1be997f7d8 | |||
| 5a8ff16afa | |||
| 4435dc32b8 | |||
| f5a33e0c1e | |||
| a8326b6f5a | |||
| 1e999d968a | |||
| d0f74c9f02 | |||
| f14f1e41b9 | |||
| 9138e722bb | |||
| 9695e269e3 | |||
| 82bdf62c7b | |||
| 8893e3afe0 | |||
| cd1e7d8fa8 | |||
| 059e72df69 | |||
| dc519b3ea7 | |||
| ad4074f107 | |||
| 0534d7b3a7 | |||
| a0554c2346 | |||
| bc5af23896 | |||
| 92be8a7ca5 | |||
| 44ea1ea0ba | |||
| 3bee600096 | |||
| 5b4030f86b | |||
| e2c00ba18b | |||
| 1050029842 | |||
| 64edf899ac | |||
| 0a9075af73 | |||
| c3a5383f5e | |||
| e96813edcf | |||
| 5e36e38b72 | |||
| 890473ebd5 | |||
| 2b86d1fda7 | |||
| 1487ad97f1 | |||
| fc27b254bb | |||
| 658536c217 | |||
| 00018959c6 | |||
| 117af4a8a7 | |||
| 84e18e4954 | |||
| 090e754077 | |||
| 4322159d8e | |||
| bdf5767a5f | |||
| e8b071aecd | |||
| f128346a54 | |||
| 5bf6af7afc | |||
| 1a941a6f1d | |||
| 508e17bc11 | |||
| 4d00858b8a | |||
| 7e5b23808d | |||
| bfcc205f05 | |||
| a6a9f3bbdd | |||
| 5651c11803 | |||
| ff749611c6 | |||
| 49f7a447d3 | |||
| ea5447056b | |||
| baec23cf73 | |||
| a202f2dc95 |
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
|
||||
// ── Studio ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const rail = (page: Page) => page.locator('.st-rail')
|
||||
const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true })
|
||||
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true })
|
||||
const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
|
||||
const inspector = (page: Page) => page.locator('.st-inspector')
|
||||
const studioMenu = (page: Page) => page.locator('.st-menu').last()
|
||||
@@ -1300,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.st-back'),
|
||||
target: p => p.locator('.st-head-close'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1431,7 +1431,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
|
||||
await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
|
||||
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 401,
|
||||
"@typescript-eslint/no-misused-promises": 850,
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 226,
|
||||
"complexity": 222,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 33,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 556,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 125,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 132,
|
||||
"max-lines-per-function": 130,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1932,
|
||||
"@typescript-eslint/no-non-null-assertion": 1929,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -117,11 +117,6 @@ export const DYNAMIC_ALLOWED = [
|
||||
template: 'settings.${mode}',
|
||||
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
|
||||
},
|
||||
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
|
||||
file,
|
||||
template: 'journey.studio.${key}',
|
||||
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
|
||||
})),
|
||||
{
|
||||
file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
|
||||
template: 'reservations.${s}',
|
||||
@@ -151,6 +146,13 @@ export const DYNAMIC_ALLOWED = [
|
||||
'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',
|
||||
},
|
||||
{
|
||||
file: 'components/Studio/shapes.ts',
|
||||
template: 'journey.studio.shapeName.${shape}',
|
||||
because:
|
||||
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
|
||||
'shape of BOOK_SHAPE_GROUPS',
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
||||
"src/components/Studio/StudioShell.tsx": 1,
|
||||
"src/components/Studio/StudioSidebar.tsx": 1,
|
||||
"src/components/Studio/TravelElements.tsx": 1,
|
||||
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
|
||||
"src/components/Studio/travel/BadgeIcon.tsx": 1,
|
||||
"src/components/Todo/useTodoList.ts": 1,
|
||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||
"src/components/shared/CountryFlag.tsx": 1,
|
||||
|
||||
@@ -56,9 +56,7 @@
|
||||
"components/Studio/StudioCanvas.tsx": 12,
|
||||
"components/Studio/StudioElementsPanel.tsx": 1,
|
||||
"components/Studio/StudioExport.tsx": 1,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
"components/Studio/StudioSavedLayouts.tsx": 1,
|
||||
"components/Studio/StudioSidebar.tsx": 2,
|
||||
"components/Studio/TravelElements.tsx": 4,
|
||||
"components/Studio/TravelPreview.tsx": 1,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"components/Studio/LineHandles.tsx": 1,
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1,
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
"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/DayPlanSidebar.tsx": 2936,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Studio/TravelElements.tsx": 1738,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
@@ -26,5 +26,5 @@
|
||||
"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": 1918
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
"src/components/Settings/NotificationsTab.test.tsx": 1,
|
||||
"src/components/Settings/PasskeysSection.tsx": 1,
|
||||
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
|
||||
"src/components/Studio/StudioInspector.tsx": 1,
|
||||
"src/components/Todo/todoListModel.ts": 1,
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||
"src/components/Tours/planner/useTourPlanner.ts": 2,
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"components/Journey/moodConfig.ts": 5,
|
||||
"components/Layout/BottomNav.tsx": 3,
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 8,
|
||||
"components/Layout/InAppNotificationBell.tsx": 3,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
@@ -72,7 +72,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 8,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -103,11 +103,10 @@
|
||||
"components/Studio/PhotoView.tsx": 2,
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/StudioElementsPanel.tsx": 8,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
"components/Studio/StudioShell.tsx": 2,
|
||||
"components/Studio/StudioSidebar.tsx": 2,
|
||||
"components/Studio/TravelElements.tsx": 5,
|
||||
"components/Studio/printSheets.ts": 5,
|
||||
"components/Studio/sidebar/contentDrops.ts": 2,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
|
||||
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
|
||||
"components/SystemNotices/SystemNoticeModal.tsx": 107,
|
||||
@@ -296,7 +295,7 @@
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 9,
|
||||
"pages/InAppNotificationsPage.tsx": 2,
|
||||
"pages/JoinTripPage.tsx": 4,
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
|
||||
@@ -44,9 +44,10 @@
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/StudioElementsPanel.tsx": 2,
|
||||
"components/Studio/StudioSwatches.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 6,
|
||||
"components/Studio/TravelElements.tsx": 3,
|
||||
"components/Studio/peerColour.ts": 1,
|
||||
"components/Studio/printSheets.ts": 4,
|
||||
"components/Studio/travel/badgeLook.ts": 2,
|
||||
"components/Trips/TripMemberAvatar.tsx": 2,
|
||||
"components/Vacay/VacayPersons.tsx": 1,
|
||||
"components/shared/DialogShell.tsx": 2,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
|
||||
import { type HTMLAttributes, type MouseEvent, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -79,6 +79,7 @@ export default function AssistantHeader({
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const menu = useContextMenu();
|
||||
const moreRef = useRef<HTMLButtonElement>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const threads = thread.threads ?? [];
|
||||
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
|
||||
@@ -95,6 +96,14 @@ export default function AssistantHeader({
|
||||
run();
|
||||
};
|
||||
|
||||
// Closed by Escape or a pick while it holds the focus, the menu hands it back to its button, so the keys
|
||||
// go on reaching the window; a click or a scroll elsewhere leaves the focus where it went.
|
||||
const closeMenu = () => {
|
||||
const fromMenu = document.activeElement?.closest('[role="menu"]') != null;
|
||||
menu.close();
|
||||
if (fromMenu) moreRef.current?.focus();
|
||||
};
|
||||
|
||||
const openMenu = (e: MouseEvent) =>
|
||||
menu.open(
|
||||
e,
|
||||
@@ -187,10 +196,12 @@ export default function AssistantHeader({
|
||||
</Tooltip>
|
||||
<Tooltip label={t('assistant.window.menu')}>
|
||||
<button
|
||||
ref={moreRef}
|
||||
type="button"
|
||||
onClick={openMenu}
|
||||
aria-label={t('assistant.window.menu')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu.menu !== null}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<Ellipsis size={14} aria-hidden="true" />
|
||||
@@ -218,6 +229,7 @@ export default function AssistantHeader({
|
||||
icon={null}
|
||||
label={th.title}
|
||||
active={th.id === thread.threadId}
|
||||
current={th.id === thread.threadId}
|
||||
onClick={() => pick(() => thread.openThread(th.id))}
|
||||
trailing={
|
||||
<span className="flex-shrink-0 text-caption text-content-faint">
|
||||
@@ -234,7 +246,7 @@ export default function AssistantHeader({
|
||||
)}
|
||||
</PopoverListPanel>
|
||||
|
||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||
<ContextMenu menu={menu.menu} onClose={closeMenu} />
|
||||
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
||||
<ConfirmDialog
|
||||
isOpen={confirming}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronRight, Luggage } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import EmptyState from '../shared/EmptyState';
|
||||
@@ -8,11 +9,17 @@ import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicke
|
||||
/**
|
||||
* The user's trips to go on with in one trip's own chat, from the home chat's
|
||||
* head: that chat, its history and its changes belong to the trip. The
|
||||
* running and the next trip lead.
|
||||
* running and the next trip lead. The list opens before the trips are there,
|
||||
* so the focus goes onto the first one once they come.
|
||||
*/
|
||||
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const trips = useAssistantTripPicker();
|
||||
const first = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
first.current?.focus({ preventScroll: true });
|
||||
}, [trips]);
|
||||
|
||||
if (trips === null) {
|
||||
return (
|
||||
@@ -32,9 +39,10 @@ export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerT
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
|
||||
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
|
||||
{trips.map((trip) => (
|
||||
{trips.map((trip, i) => (
|
||||
<li key={trip.id}>
|
||||
<button
|
||||
ref={i === 0 ? first : undefined}
|
||||
type="button"
|
||||
onClick={() => onPick(trip)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-WIN-001 to FE-AI-WIN-014
|
||||
* FE-AI-WIN-001 to FE-AI-WIN-016
|
||||
*
|
||||
* The desktop window around the chat: when it shows at all, the button it
|
||||
* opens from, the home chat outside a trip with its way into one trip's chat
|
||||
@@ -238,6 +238,34 @@ describe('AssistantWindow', () => {
|
||||
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||
});
|
||||
|
||||
it("FE-AI-WIN-015: the header's menu says whether it is open, and Escape hands the focus back to its button", async () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={TRIP} />);
|
||||
const more = screen.getByRole('button', { name: 'More options' });
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(more);
|
||||
expect(more).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
|
||||
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
|
||||
expect(more).toHaveFocus();
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false');
|
||||
// From the button the next Escape is the window's again.
|
||||
fireEvent.keyDown(more, { key: 'Escape' });
|
||||
expect(useAssistantStore.getState().window.open).toBe(false);
|
||||
});
|
||||
|
||||
it("FE-AI-WIN-016: the home chat's trip list takes the focus onto the first trip once the trips are there", async () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={null} />);
|
||||
const button = screen.getByRole('button', { name: 'Chat about one trip' });
|
||||
fireEvent.click(button);
|
||||
// The list's own row, not the chat's "go on in Lisbon" beside it.
|
||||
const lisbon = await screen.findByRole('button', { name: /^Lisbon/ });
|
||||
await waitFor(() => expect(lisbon).toHaveFocus());
|
||||
fireEvent.keyDown(lisbon, { key: 'Escape' });
|
||||
expect(button).toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
|
||||
seed({ open: true, minimized: false });
|
||||
const onFocusDay = vi.fn();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-016
|
||||
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-017
|
||||
*
|
||||
* The pure side of the book's page card: a page named as the Studio names
|
||||
* it, a layout and a finding by their words, the findings worth a look, the
|
||||
@@ -8,7 +8,7 @@
|
||||
* the card carries it and the book is still at the trial's version), and
|
||||
* where an applied change opens the Studio.
|
||||
*/
|
||||
import { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
|
||||
import { BOOK_FINDING_CODES, blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -67,6 +67,9 @@ describe('bookCardModel', () => {
|
||||
it('FE-AI-BOOKPAGE-011: a layout is named by its template, its grid, its own name or as laid out again', () => {
|
||||
expect(layoutLabel({ kind: 'template', id: 'two-up', name: null }, t)).toBe('journey.studio.tpl.twoUp');
|
||||
expect(layoutLabel({ kind: 'template', id: 'cover-full', name: null }, t)).toBe('journey.studio.tpl.coverFull');
|
||||
expect(layoutLabel({ kind: 'template', id: 'postcard-houses', name: null }, t)).toBe(
|
||||
'studio.templates.name.postcardHouses'
|
||||
);
|
||||
expect(layoutLabel({ kind: 'grid', id: 'two-across', name: null }, t)).toBe('assistant.card.book.grid {"count":2}');
|
||||
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: 'Mine' }, t)).toBe('Mine');
|
||||
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1');
|
||||
@@ -92,6 +95,15 @@ describe('bookCardModel', () => {
|
||||
expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi');
|
||||
});
|
||||
|
||||
it('FE-AI-BOOKPAGE-017: every finding the checks answer with has its words, the art checks among them', () => {
|
||||
for (const code of BOOK_FINDING_CODES) {
|
||||
expect(findingLabel({ code, severity: 'info' }, t)).toBe(`assistant.card.book.finding.${code}`);
|
||||
}
|
||||
for (const code of ['flat_cover', 'weak_hierarchy', 'off_theme', 'energy_mismatch'] as const) {
|
||||
expect(BOOK_FINDING_CODES).toContain(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => {
|
||||
const doc = book();
|
||||
const [edit] = pageViewOf(card(), doc, 'pending');
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type BookSpread,
|
||||
findBookGrid,
|
||||
findBookTemplate,
|
||||
findDecoratedTemplate,
|
||||
setBookCover,
|
||||
} from '@trek/shared';
|
||||
|
||||
@@ -62,6 +63,10 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
|
||||
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' },
|
||||
flat_cover: { labelKey: 'assistant.card.book.finding.flat_cover' },
|
||||
weak_hierarchy: { labelKey: 'assistant.card.book.finding.weak_hierarchy' },
|
||||
off_theme: { labelKey: 'assistant.card.book.finding.off_theme' },
|
||||
energy_mismatch: { labelKey: 'assistant.card.book.finding.energy_mismatch' },
|
||||
};
|
||||
|
||||
/** A page as the Studio names it: the cover, the first page, double page 3. */
|
||||
@@ -87,7 +92,7 @@ export function layoutLabel(layout: BookCardLayout | null, t: Translate): string
|
||||
const grid = findBookGrid(layout.id);
|
||||
return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null;
|
||||
}
|
||||
const template = findBookTemplate(layout.id);
|
||||
const template = findBookTemplate(layout.id) ?? findDecoratedTemplate(layout.id);
|
||||
return template ? t(template.labelKey) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ 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' },
|
||||
list_book_assets: { stepKey: 'assistant.step.bookAssets' },
|
||||
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
|
||||
},
|
||||
entities: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-TZHOOK-001 to -007: the world clocks both dashboards share.
|
||||
// FE-COMP-TZHOOK-001 to -009: the world clocks both dashboards share.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -97,4 +97,27 @@ describe('useDashboardTimezones', () => {
|
||||
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #2694: Chrome on Windows names no zone for a system zone it cannot map.
|
||||
it('FE-COMP-TZHOOK-008: a browser that names no zone still gets the default clocks', () => {
|
||||
const original = Intl.DateTimeFormat.prototype.resolvedOptions;
|
||||
const spy = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (
|
||||
this: Intl.DateTimeFormat
|
||||
) {
|
||||
return { ...original.call(this), timeZone: undefined as unknown as string };
|
||||
});
|
||||
try {
|
||||
const { result } = renderHook(() => useDashboardTimezones('en'));
|
||||
expect(result.current.zones).toEqual(['Europe/London', 'Asia/Tokyo']);
|
||||
expect(() => result.current.zones.map((tz) => result.current.timeIn(tz))).not.toThrow();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-TZHOOK-009: a stored zone this browser does not know is left out instead of crashing the clocks', () => {
|
||||
seedSettings(['Europe/Berlin', 'Mars/Olympus_Mons', '']);
|
||||
const { result } = renderHook(() => useDashboardTimezones('en'));
|
||||
expect(result.current.zones).toEqual(['Europe/Berlin']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { browserTimeZone, isValidTimeZone } from '../../utils/localDate';
|
||||
|
||||
const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo'];
|
||||
|
||||
@@ -38,13 +39,15 @@ export function shortZone(tz: string): string {
|
||||
* clocks tick every 30 seconds, `adding` toggles the zone picker.
|
||||
*/
|
||||
export function useDashboardTimezones(locale: string) {
|
||||
const home = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const home = browserTimeZone();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const isLoaded = useSettingsStore((s) => s.isLoaded);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const stored = useSettingsStore((s) => s.settings.dashboard_timezones);
|
||||
// Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
|
||||
const zones = stored ?? [home, ...DEFAULT_ZONES];
|
||||
// A browser that names no zone (#2694) or a zone this browser does not know
|
||||
// would throw while rendering a clock, so such entries are left out.
|
||||
const zones = (stored ?? [home, ...DEFAULT_ZONES]).filter(isValidTimeZone);
|
||||
const setZones = (next: string[]) => {
|
||||
updateSetting('dashboard_timezones', next).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -68,12 +68,12 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
{/* Click-away catcher only — the bell button itself closes the panel again from the keyboard. */}
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
||||
className="rounded-2xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
insetInlineEnd: 8,
|
||||
width: 360,
|
||||
width: 380,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
@@ -81,14 +81,15 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-edge-secondary"
|
||||
>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{/* Header: the dialogs' head, a tile, the name and how many are unread, the tools at the end. */}
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0">
|
||||
<span className="grid place-items-center w-9 h-9 rounded-xl bg-accent-subtle text-accent flex-shrink-0">
|
||||
<Bell className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 flex items-center gap-2 text-subtitle font-semibold text-content">
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ms-2 px-1.5 py-0.5 rounded-full text-xs font-medium bg-content text-surface">
|
||||
<span className="px-2 h-5 inline-flex items-center rounded-full text-caption font-semibold tabular-nums bg-accent text-accent-text">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -98,34 +99,38 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<button type="button"
|
||||
onClick={markAllRead}
|
||||
title={t('notifications.markAllRead')}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
aria-label={t('notifications.markAllRead')}
|
||||
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button type="button"
|
||||
onClick={() => setConfirmingDeleteAll(true)}
|
||||
title={t('notifications.deleteAll')}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
aria-label={t('notifications.deleteAll')}
|
||||
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
|
||||
<Bell className="w-8 h-8 text-content-faint" />
|
||||
<p className="text-sm font-medium text-content-muted">{t('notifications.empty')}</p>
|
||||
<p className="text-xs text-content-faint">{t('notifications.emptyDescription')}</p>
|
||||
<div className="flex flex-col items-center justify-center py-10 px-6 text-center gap-2">
|
||||
<span className="grid place-items-center w-12 h-12 rounded-2xl bg-surface-secondary text-content-faint">
|
||||
<Bell className="w-5 h-5" />
|
||||
</span>
|
||||
<p className="text-body font-semibold text-content">{t('notifications.empty')}</p>
|
||||
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
@@ -135,12 +140,14 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<button type="button"
|
||||
onClick={handleShowAll}
|
||||
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0 border-t border-edge-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
>
|
||||
{t('notifications.showAll')}
|
||||
</button>
|
||||
<div className="p-2 flex-shrink-0 border-t border-edge-secondary">
|
||||
<button type="button"
|
||||
onClick={handleShowAll}
|
||||
className="w-full h-9 rounded-xl text-body font-medium transition-colors bg-surface-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
>
|
||||
{t('notifications.showAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
|
||||
@@ -13,6 +13,18 @@ interface NotificationItemProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** The round tool beside a notification, quiet until it is pointed at. */
|
||||
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-card focus-visible:bg-surface-card focus-visible:text-content'
|
||||
|
||||
/** An answer or a way on, as a pill under the words. */
|
||||
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
|
||||
|
||||
/**
|
||||
* One notification, as a card in the list: who sent it, what it says, and what
|
||||
* can be done with it. Unread ones carry a dot in the accent and a faint
|
||||
* ground; the tools to mark and delete come forward when the card is pointed
|
||||
* at or focused, so a list reads as messages rather than as rows of buttons.
|
||||
*/
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const { responding, handleRespond, handleNavigate, markRead, markUnread, deleteNotification } =
|
||||
@@ -22,29 +34,25 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const hasUnknownTitle = titleText === notification.title_key
|
||||
const hasUnknownBody = bodyText === notification.text_key
|
||||
const unread = !notification.is_read
|
||||
const answered = notification.response !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative px-4 py-3 transition-colors border-b border-edge-secondary ${notification.is_read ? '' : 'bg-accent-subtle'}`}
|
||||
className={`group relative mx-2 my-1 rounded-xl px-3 py-3 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover ${unread ? 'bg-surface-secondary' : ''}`}
|
||||
>
|
||||
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div className="flex-shrink-0">
|
||||
{notification.sender_avatar ? (
|
||||
<img
|
||||
src={notification.sender_avatar}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-content-muted bg-surface-tertiary"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />
|
||||
}
|
||||
: <User className="w-4 h-4" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -52,72 +60,67 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug text-content">
|
||||
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
|
||||
{hasUnknownTitle ? notification.title_key : titleText}
|
||||
</p>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span className="text-xs me-1 text-content-faint">
|
||||
<div className="relative flex-shrink-0 h-7 flex items-center">
|
||||
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
{notification.is_read ? (
|
||||
<button type="button"
|
||||
onClick={() => markUnread(notification.id)}
|
||||
title={t('notifications.markUnread')}
|
||||
aria-label={t('notifications.markUnread')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
|
||||
<div className="absolute end-0 top-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{notification.is_read ? (
|
||||
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button"
|
||||
onClick={() => markRead(notification.id)}
|
||||
title={t('notifications.markRead')}
|
||||
aria-label={t('notifications.markRead')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mt-0.5 leading-relaxed text-content-muted">
|
||||
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
|
||||
{/* Boolean actions */}
|
||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button type="button"
|
||||
<div className="flex gap-2 mt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'positive'
|
||||
? 'bg-content text-surface'
|
||||
: `bg-surface-tertiary ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
|
||||
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
? 'bg-accent text-accent-text'
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t(notification.positive_text_key)}
|
||||
</button>
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'negative'
|
||||
? 'bg-danger text-white'
|
||||
: `bg-surface-tertiary ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
|
||||
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t(notification.negative_text_key)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -125,12 +128,13 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
|
||||
{/* Navigate action */}
|
||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors bg-surface-tertiary text-content-secondary hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
className={`${PILL} mt-2.5 bg-surface-card shadow-sm text-content hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text`}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
{t(notification.navigate_text_key)}
|
||||
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -397,6 +397,19 @@ describe('downloadTripPDF', () => {
|
||||
expect(iframe!.srcdoc).toContain('€')
|
||||
})
|
||||
|
||||
// #2692: a place's description and notes print as the planner shows them, not as raw markdown.
|
||||
it('FE-COMP-TRIPPDF-016c: renders markdown in a place description, sanitized', async () => {
|
||||
const description = ['**Colosseum** tickets', '', '| Gate | Time |', '| --- | --- |', '| A | 9:00 |', '', '<script>alert(1)</script>'].join('\n')
|
||||
const place = { ...placeWithDetails, description }
|
||||
const md = { ...richArgs, places: [place], assignments: { '10': [{ ...assignmentForDay, place }] } as typeof richArgs.assignments }
|
||||
await downloadTripPDF(md)
|
||||
const html = getIframe()?.srcdoc ?? ''
|
||||
expect(html).toContain('<strong>Colosseum</strong>')
|
||||
expect(html).toContain('<td>9:00</td>')
|
||||
expect(html).not.toContain('**Colosseum**')
|
||||
expect(html).not.toContain('<script>alert(1)</script>')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-016b: formats price chip and totals in the trip currency, not EUR', async () => {
|
||||
const usdArgs = {
|
||||
...richArgs,
|
||||
@@ -796,7 +809,7 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('ICE 599 · Gl. 7 · Seat 21A · FRA → BER')
|
||||
expect(html).toContain('ICE 599 · reservations.meta.platform 7 · reservations.meta.seat 21A · FRA → BER')
|
||||
})
|
||||
|
||||
it('FE-W5PDF-006: a multi-leg train renders one line per leg', async () => {
|
||||
@@ -811,7 +824,7 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('IC 1 · Gl. 3 · BER → MUC')
|
||||
expect(html).toContain('IC 1 · reservations.meta.platform 3 · BER → MUC')
|
||||
expect(html).toContain('EC 2 · MUC → ZRH')
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { fetchExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs'
|
||||
import { isServiceStopType } from '../Roadtrip/roadtripModel'
|
||||
import { onlyMyPlan } from './pdfScope'
|
||||
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
@@ -231,6 +232,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
let hasTransportNotes = false
|
||||
const loc = _locale || undefined
|
||||
const tr = _t || (k => k)
|
||||
const markdown = createPdfMarkdown()
|
||||
// The store read is the fallback, not the source: a caller that forgets the
|
||||
// prop still prints the reader's own format instead of silently reverting.
|
||||
const is12h = (_timeFormat || useSettingsStore.getState().settings.time_format || '24h') === '12h'
|
||||
@@ -439,8 +441,8 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
const source = (usesStationRoute(r.type) ? getTrainLegs(r) : getFlightLegs(r))[l.index]
|
||||
subtitleLines = [[
|
||||
l.airline, l.flight_number, l.train_number,
|
||||
l.platform ? `Gl. ${l.platform}` : '',
|
||||
l.seat ? `Seat ${l.seat}` : '',
|
||||
l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
|
||||
l.seat ? `${tr('reservations.meta.seat')} ${l.seat}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
source?.confirmation_number,
|
||||
].filter(Boolean).join(' · ')].filter(Boolean)
|
||||
@@ -471,7 +473,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
// Multi-leg: one line per leg so every train number + segment route shows,
|
||||
// with the segment's own booking code where it has one (#1943).
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
|
||||
[l.train_number, l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
l.confirmation_number]
|
||||
.filter(Boolean).join(' · '))
|
||||
@@ -479,7 +481,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
} else {
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : ''
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
subtitle = [meta.train_number, meta.platform ? `${tr('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${tr('reservations.meta.seat')} ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'car') {
|
||||
@@ -587,10 +589,10 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
</div>
|
||||
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
||||
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
|
||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||
${place.description ? markdown.row(place.description) : ''}
|
||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
${item.data.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(item.data.notes)}</span></div>` : ''}
|
||||
${place.notes ? markdown.row(place.notes) : ''}
|
||||
${item.data.notes ? markdown.row(item.data.notes) : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
@@ -836,7 +838,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
.info-spacer { width: 13px; flex-shrink: 0; }
|
||||
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
|
||||
.info-text.muted { color: #94a3b8; }
|
||||
.info-text.italic { font-style: italic; }
|
||||
${PDF_MARKDOWN_CSS}
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
|
||||
@@ -930,6 +932,7 @@ ${tripMapHtml}
|
||||
${daysHtml}
|
||||
${pluginSectionsHtml}
|
||||
</body></html>`
|
||||
markdown.dispose()
|
||||
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createMarkdownHtml } from '../shared/markdownHtml';
|
||||
|
||||
/**
|
||||
* A place's description and notes are markdown in the app, so the export
|
||||
* renders them the same way instead of printing the raw syntax (#2692): one
|
||||
* info row per text, the HTML from the app's own sanitized pipeline.
|
||||
*/
|
||||
export function createPdfMarkdown(): { row: (text: string) => string; dispose: () => void } {
|
||||
const markdown = createMarkdownHtml();
|
||||
return {
|
||||
row: (text) =>
|
||||
`<div class="info-row"><span class="info-spacer"></span><div class="info-text muted md">${markdown.html(text)}</div></div>`,
|
||||
dispose: markdown.dispose,
|
||||
};
|
||||
}
|
||||
|
||||
/** The markdown's own spacing, lists, tables and links at the export's small size. */
|
||||
export const PDF_MARKDOWN_CSS = `
|
||||
.info-text.md { min-width: 0; overflow-wrap: anywhere; }
|
||||
.md > :first-child { margin-top: 0; }
|
||||
.md > :last-child { margin-bottom: 0; }
|
||||
.md p, .md ul, .md ol, .md table, .md blockquote, .md pre { margin: 0 0 4px; }
|
||||
.md ul, .md ol { padding-inline-start: 14px; }
|
||||
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-size: 1.1em; font-weight: 700; margin: 4px 0 2px; }
|
||||
.md table { border-collapse: collapse; }
|
||||
.md th, .md td { border: 1px solid #e2e8f0; padding: 2px 5px; text-align: start; }
|
||||
.md code { font-family: ui-monospace, monospace; font-size: 8.5px; }
|
||||
.md a { color: inherit; text-decoration: underline; }
|
||||
.md blockquote { border-inline-start: 2px solid #e2e8f0; padding-inline-start: 6px; }
|
||||
`;
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PACKPOP-001 to FE-PACKPOP-008
|
||||
// FE-PACKPOP-001 to FE-PACKPOP-009
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { useRef } from 'react'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
@@ -56,6 +56,15 @@ describe('PopoverItem', () => {
|
||||
expect(danger.style.background).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-PACKPOP-009: the entry open now is said to be the current one, not only tinted', () => {
|
||||
render(<>
|
||||
<PopoverItem icon={null} label="Temples" active current onClick={() => {}} />
|
||||
<PopoverItem icon={null} label="Food" onClick={() => {}} />
|
||||
</>)
|
||||
expect(screen.getByRole('button', { name: 'Temples' })).toHaveAttribute('aria-current', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Food' })).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('FE-PACKPOP-004: the composer button fades while there is nothing to add', () => {
|
||||
expect(composerConfirm(false)).toMatchObject({ opacity: 0.35, cursor: 'default' })
|
||||
expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' })
|
||||
|
||||
@@ -7,6 +7,8 @@ interface PopoverItemProps {
|
||||
label: string
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
/** The entry is the one open now (a chat in a history list), said so to a screen reader too. */
|
||||
current?: boolean
|
||||
danger?: boolean
|
||||
/** A secondary line, such as Add bag, in the faint text colour. */
|
||||
muted?: boolean
|
||||
@@ -14,11 +16,12 @@ interface PopoverItemProps {
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
export function PopoverItem({ icon, label, onClick, active = false, danger = false, muted = false, trailing }: PopoverItemProps) {
|
||||
export function PopoverItem({ icon, label, onClick, active = false, current = false, danger = false, muted = false, trailing }: PopoverItemProps) {
|
||||
const tone = danger ? '#ef4444' : muted ? 'var(--text-faint)' : 'var(--text-primary)'
|
||||
return (
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
aria-current={current || undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
|
||||
padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer',
|
||||
|
||||
@@ -43,6 +43,7 @@ import './dayPlanRows.css'
|
||||
|
||||
import { TypeTile } from './bookings/bookingParts'
|
||||
import { typeInfo } from './bookings/bookingsModel'
|
||||
import { transportSubtitle } from './transportDetailModel'
|
||||
import { dayHeadStays } from './dayHeadStays'
|
||||
import { getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import {
|
||||
@@ -2166,24 +2167,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
if (res.__leg) {
|
||||
// One leg of a multi-leg flight/train — show this segment's own detail.
|
||||
const parts = res.type === 'train'
|
||||
? [res.__leg.train_number, res.__leg.platform ? `${t('reservations.meta.platform')} ${res.__leg.platform}` : '', res.__leg.seat ? `${t('reservations.meta.seat')} ${res.__leg.seat}` : ''].filter(Boolean)
|
||||
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
if (res.__leg.from || res.__leg.to)
|
||||
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(', ')
|
||||
} else if (res.type === 'flight') {
|
||||
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||
if (meta.departure_airport || meta.arrival_airport)
|
||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(', ')
|
||||
} else if (res.type === 'train') {
|
||||
subtitle = [meta.train_number, meta.platform ? `${t('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${t('reservations.meta.seat')} ${meta.seat}` : ''].filter(Boolean).join(', ')
|
||||
}
|
||||
// A leg of a multi-leg flight or train shows its own detail (#2701).
|
||||
const subtitle = transportSubtitle(res, t) ?? ''
|
||||
|
||||
// A transit journey (#1065) renders its itinerary inline —
|
||||
// line badges in their colors instead of a plain subtitle,
|
||||
|
||||
@@ -23,3 +23,65 @@ export function bookingDayLabel(date: string, locale: string): string {
|
||||
export function segmentCodeLabel(leg: { from?: string | null; to?: string | null }, t: Translate): string {
|
||||
return [leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode');
|
||||
}
|
||||
|
||||
/** The per-leg detail the day plan's leg rows carry (utils/dayMerge's `__leg`). */
|
||||
export interface TransportLegDetail {
|
||||
index: number;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
airline?: string | null;
|
||||
flight_number?: string | null;
|
||||
train_number?: string | null;
|
||||
platform?: string | null;
|
||||
seat?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtitle of a transport row in the day plan, desktop and phone alike. A
|
||||
* leg of a multi-leg train or flight names its own train or flight, platform
|
||||
* and seat (#2701); a whole booking reads them off its metadata. Null for the
|
||||
* other transport types, which each view labels its own way.
|
||||
*/
|
||||
export function transportSubtitle(
|
||||
res: { type: string; metadata?: unknown; __leg?: TransportLegDetail | null },
|
||||
t: Translate
|
||||
): string | null {
|
||||
const meta = parseMeta(res.metadata);
|
||||
const leg = res.__leg;
|
||||
const trainParts = (d: { train_number?: unknown; platform?: unknown; seat?: unknown }) => [
|
||||
str(d.train_number),
|
||||
str(d.platform) ? `${t('reservations.meta.platform')} ${str(d.platform)}` : '',
|
||||
str(d.seat) ? `${t('reservations.meta.seat')} ${str(d.seat)}` : '',
|
||||
];
|
||||
if (leg) {
|
||||
const parts = res.type === 'train' ? trainParts(leg) : [str(leg.airline), str(leg.flight_number)];
|
||||
if (leg.from || leg.to) parts.push([leg.from, leg.to].filter(Boolean).join(' → '));
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
if (res.type === 'flight') {
|
||||
const parts = [str(meta.airline), str(meta.flight_number)];
|
||||
if (meta.departure_airport || meta.arrival_airport) {
|
||||
parts.push([str(meta.departure_airport), str(meta.arrival_airport)].filter(Boolean).join(' → '));
|
||||
}
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
if (res.type === 'train') return trainParts(meta).filter(Boolean).join(', ');
|
||||
return null;
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value) : '';
|
||||
}
|
||||
|
||||
function parseMeta(metadata: unknown): Record<string, unknown> {
|
||||
let meta = metadata;
|
||||
// An earlier bug could double-encode the metadata; reading it twice heals that.
|
||||
for (let i = 0; i < 2 && typeof meta === 'string'; i++) {
|
||||
try {
|
||||
meta = JSON.parse(meta || '{}');
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
}
|
||||
return meta && typeof meta === 'object' ? (meta as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { browserTimeZone } from '../../utils/localDate'
|
||||
|
||||
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
|
||||
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
|
||||
@@ -379,7 +380,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
timeFormat: settings.time_format,
|
||||
distanceUnit: settings.distance_unit,
|
||||
temperatureUnit: settings.temperature_unit,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timezone: browserTimeZone() ?? 'UTC',
|
||||
blurBookingCodes: Boolean(settings.blur_booking_codes),
|
||||
},
|
||||
tokens: readThemeTokens(),
|
||||
|
||||
@@ -15,15 +15,13 @@ import type { SaveState } from './useBookStore'
|
||||
* which is exactly the wrong habit for the one time it says something else.
|
||||
*/
|
||||
export function SaveIndicator({
|
||||
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
|
||||
state, t, onAcceptTheirs, onKeepMine, onRetry,
|
||||
}: {
|
||||
state: SaveState
|
||||
t: (k: string) => string
|
||||
onAcceptTheirs: (current: BookRecord) => void
|
||||
onKeepMine: (current: BookRecord) => void
|
||||
onRetry: () => void
|
||||
/** Load this Studio again, for a book a newer one saved. A full reload by default. */
|
||||
onReload?: () => void
|
||||
}) {
|
||||
if (state.status === 'conflict') {
|
||||
return (
|
||||
@@ -45,23 +43,8 @@ export function SaveIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status === 'newer') {
|
||||
/*
|
||||
* The one state with a way out the user has to take themselves: a newer
|
||||
* Studio saved this book, this tab cannot write it without losing what
|
||||
* that one added, and only a reload brings the newer Studio here. Loud
|
||||
* like a conflict, because until then nothing done here is kept.
|
||||
*/
|
||||
return (
|
||||
<div className="st-save is-conflict" role="alert">
|
||||
<AlertTriangle size={13} />
|
||||
<span>{t('journey.studio.saveNewer')}</span>
|
||||
<button type="button" onClick={onReload}>
|
||||
{t('journey.studio.saveReload')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// A book a newer Studio saved is the shell's to say, over everything (NewerBookOverlay).
|
||||
if (state.status === 'newer') return null
|
||||
|
||||
if (state.status === 'readonly') {
|
||||
/*
|
||||
@@ -107,6 +90,32 @@ export function SaveIndicator({
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A book a newer Studio saved, said over the whole Studio rather than in the
|
||||
* bar: this tab cannot write the book without losing what the newer one
|
||||
* added, and only a reload brings the newer Studio here. Until then nothing
|
||||
* done here would be kept, so nothing can be done: the overlay takes every
|
||||
* click and offers the one way out there is.
|
||||
*/
|
||||
export function NewerBookOverlay({ t, onReload = reloadStudio }: {
|
||||
t: (k: string) => string
|
||||
onReload?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="st-newer" role="alertdialog" aria-modal="true" aria-labelledby="st-newer-text">
|
||||
<div className="st-newer-card">
|
||||
<span className="st-newer-icon"><RefreshCw size={22} strokeWidth={2.2} /></span>
|
||||
<p id="st-newer-text" className="st-newer-text">{t('journey.studio.saveNewer')}</p>
|
||||
{/* Focused on arrival, so Enter reloads and nothing behind the overlay takes the keyboard. */}
|
||||
<button type="button" className="st-newer-reload" onClick={onReload} autoFocus>
|
||||
<RefreshCw size={15} />
|
||||
{t('journey.studio.saveReload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The default way back to a current Studio: the page again, which loads the newest build. */
|
||||
function reloadStudio() {
|
||||
window.location.reload()
|
||||
|
||||
@@ -18,6 +18,12 @@ interface SpreadThumbProps {
|
||||
className?: string;
|
||||
/** Pixels per millimetre the caller draws at; CSS's own unless the caller zooms. */
|
||||
pxPerMm?: number;
|
||||
/**
|
||||
* Draw the first and the last page beside the blank inside of their cover,
|
||||
* as the bound book opens on them, so the rail shows why they stand alone.
|
||||
* The words are the half's name, read out and shown on hover.
|
||||
*/
|
||||
coverInside?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,16 +43,31 @@ export function SpreadThumb({
|
||||
fold = false,
|
||||
className,
|
||||
pxPerMm = PX_PER_MM,
|
||||
coverInside,
|
||||
}: SpreadThumbProps) {
|
||||
const single = spread.role !== 'inner';
|
||||
const wMm = single ? page.pageWidth : page.pageWidth * 2;
|
||||
// The first page opens on the right of the cover's inside, the last on the left of the back's.
|
||||
const opens = coverInside && (spread.role === 'first' || spread.role === 'last') ? spread.role : null;
|
||||
const wMm = single && !opens ? page.pageWidth : page.pageWidth * 2;
|
||||
const scale = width / (wMm * pxPerMm);
|
||||
const pageLeft = opens === 'first' ? page.pageWidth * pxPerMm * scale : 0;
|
||||
return (
|
||||
<div dir="ltr" className={className} style={{ width, height: page.pageHeight * pxPerMm * scale }}>
|
||||
{opens && (
|
||||
<span
|
||||
className="st-thumb-inside"
|
||||
title={coverInside}
|
||||
aria-label={coverInside}
|
||||
style={{
|
||||
insetInlineStart: opens === 'first' ? 0 : page.pageWidth * pxPerMm * scale,
|
||||
width: page.pageWidth * pxPerMm * scale,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 0,
|
||||
insetInlineStart: pageLeft,
|
||||
top: 0,
|
||||
width: `${wMm}mm`,
|
||||
height: `${page.pageHeight}mm`,
|
||||
@@ -56,7 +77,7 @@ export function SpreadThumb({
|
||||
>
|
||||
<SpreadView spread={spread} page={page} folios={folios} />
|
||||
</div>
|
||||
{fold && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
|
||||
{fold && (!single || opens) && <SpreadFold page={page} scaled={pxPerMm * scale} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,26 +9,45 @@ import {
|
||||
AlignStartHorizontal,
|
||||
AlignStartVertical,
|
||||
AlignVerticalDistributeCenter,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronsDown,
|
||||
ChevronsUp,
|
||||
Group,
|
||||
Layers,
|
||||
Paintbrush,
|
||||
Pipette,
|
||||
Ungroup,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-panels.css';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { Choice, Section } from './StudioControls';
|
||||
import { Choice, Line, Section } from './StudioControls';
|
||||
import { useStudioShortcut } from './studioShortcut';
|
||||
|
||||
/**
|
||||
* Align, distribute, group and the style clipboard, for whatever is selected.
|
||||
* Arrange: where the selection sits among the other things on the page.
|
||||
*
|
||||
* The align bar lines a multi-selection up within its own box by default, and
|
||||
* Lining it up and spacing it out, what it lines up against, what lies in
|
||||
* front of what, grouping, and the style clipboard, in one group at the foot
|
||||
* of the inspector. These were two groups, Align and Arrange, one under the
|
||||
* other and both about the selection's place among its neighbours, so the
|
||||
* stacking buttons were a fold away from the alignment they are used with.
|
||||
*
|
||||
* Every line puts its row under its label, across the whole width of the
|
||||
* panel: the six edges and the two spacings are eight buttons, more than fit
|
||||
* beside a label. Each row is two sets, the edges and the spacings, the stack
|
||||
* and the grouping, and a set that no longer fits moves to the next line
|
||||
* whole, so a narrow panel shows two even rows rather than a ragged one.
|
||||
*
|
||||
* The align row 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.
|
||||
* (shared/src/book/book-arrange.ts). The Layers button opens the rail's
|
||||
* Layers panel, the whole stack of the page beside the selection. Every press
|
||||
* is one undo step.
|
||||
*/
|
||||
|
||||
const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
|
||||
@@ -40,6 +59,13 @@ const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
|
||||
{ edge: 'bottom', icon: AlignEndHorizontal, labelKey: 'journey.studio.layers.alignBottom' },
|
||||
];
|
||||
|
||||
const STACK: { to: 'front' | 'up' | 'down' | 'back'; icon: LucideIcon; labelKey: string }[] = [
|
||||
{ to: 'front', icon: ChevronsUp, labelKey: 'journey.studio.toFront' },
|
||||
{ to: 'up', icon: ArrowUp, labelKey: 'journey.studio.forward' },
|
||||
{ to: 'down', icon: ArrowDown, labelKey: 'journey.studio.backward' },
|
||||
{ to: 'back', icon: ChevronsDown, labelKey: 'journey.studio.toBack' },
|
||||
];
|
||||
|
||||
/** How many things move as one in a selection: a group counts once. */
|
||||
function unitCount(spread: BookSpread, ids: string[]): number {
|
||||
return distributableCount(
|
||||
@@ -70,18 +96,29 @@ function IconButton({
|
||||
);
|
||||
}
|
||||
|
||||
export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number; t: (k: string) => string }) {
|
||||
export function StudioArrangeSection({
|
||||
spreadIndex,
|
||||
t,
|
||||
onShowLayers,
|
||||
}: {
|
||||
spreadIndex: number;
|
||||
t: (k: string) => string;
|
||||
/** Opens the Layers panel in the rail; without it the button is left out. */
|
||||
onShowLayers?: () => void;
|
||||
}) {
|
||||
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 raise = useStudioStore((s) => s.raise);
|
||||
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);
|
||||
const chosenTo = useStudioStore((s) => s.alignTo);
|
||||
const setChosenTo = useStudioStore((s) => s.setAlignTo);
|
||||
|
||||
if (!spread || !selection.length) return null;
|
||||
const sel = spread.elements.filter((e) => selection.includes(e.id));
|
||||
@@ -95,76 +132,102 @@ export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number;
|
||||
|
||||
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)}
|
||||
<Section key={units > 1 ? 'many' : 'one'} label={t('journey.studio.arrange')} defaultOpen={units > 1}>
|
||||
<div className="st-arrange">
|
||||
<Line label={t('journey.studio.layers.align')} wide>
|
||||
<div className="st-row st-arrange-row">
|
||||
<span className="st-arrange-set">
|
||||
{ALIGN.map((a) => (
|
||||
<IconButton
|
||||
key={a.edge}
|
||||
label={t(a.labelKey)}
|
||||
icon={a.icon}
|
||||
disabled={!movable.length}
|
||||
onClick={() => align(spreadIndex, a.edge, to)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="st-arrange-set">
|
||||
<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')}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.layers.alignTo')} wide>
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
<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)}
|
||||
/>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.order')} wide>
|
||||
<div className="st-row st-arrange-row">
|
||||
<span className="st-arrange-set">
|
||||
{STACK.map((s) => (
|
||||
<IconButton
|
||||
key={s.to}
|
||||
label={t(s.labelKey)}
|
||||
icon={s.icon}
|
||||
onClick={() => raise(spreadIndex, selection, s.to)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="st-arrange-set">
|
||||
{(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)}
|
||||
/>
|
||||
)}
|
||||
{onShowLayers && (
|
||||
<IconButton label={t('journey.studio.showLayers')} icon={Layers} onClick={onShowLayers} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.style')} wide>
|
||||
<div className="st-row">
|
||||
<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>
|
||||
</Line>
|
||||
</div>
|
||||
{asGroup && <p className="st-hint st-arrange-hint">{t('journey.studio.layers.groupHint')}</p>}
|
||||
</Section>
|
||||
|
||||
@@ -215,13 +215,13 @@ export function StudioAssistantToggle({ assistant }: { assistant: StudioAssistan
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`st-tool st-assistant-toggle ${assistant.open ? 'is-on' : ''}`}
|
||||
className={`st-tool is-icon st-head-solo st-assistant-toggle ${assistant.open ? 'is-on' : ''}`}
|
||||
onClick={assistant.toggle}
|
||||
aria-pressed={assistant.open}
|
||||
aria-label={t('journey.studio.assistant.toggle')}
|
||||
title={t('journey.studio.assistant.toggleHint')}
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span className="st-tool-label">{t('journey.studio.assistant.toggle')}</span>
|
||||
<MessageSquare size={15} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bookElementId, spreadPaint, withSpreadPaint, type BookPageSetup } from '@trek/shared';
|
||||
import { bookElementId, spreadPaint, withSpreadPaintByHand, type BookPageSetup } from '@trek/shared';
|
||||
import { Check, CopyCheck, ImageOff } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
@@ -37,9 +37,10 @@ export function StudioBackgroundSection({
|
||||
}) {
|
||||
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
|
||||
const commit = useStudioStore((s) => s.commit);
|
||||
// A colour picked here is the person's, so the page stops following the theme's paper (as the assistant's does).
|
||||
const channel = usePaintChannel((paint) => (doc) => ({
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaint(sp, paint) : sp)),
|
||||
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaintByHand(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.
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { BookDocument, BookElement } from '@trek/shared';
|
||||
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { AngleDial } from './paint/AngleDial';
|
||||
import { GradientStopBar } from './paint/GradientStopBar';
|
||||
import { StudioCanvas } from './StudioCanvas';
|
||||
|
||||
/**
|
||||
@@ -267,6 +269,55 @@ describe('the keyboard', () => {
|
||||
expect(again.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a key to the control that takes it: a gradient stop, the angle dial', () => {
|
||||
load([shape('a', 10, 10)]);
|
||||
store().select(['a']);
|
||||
draw();
|
||||
const fade = (n: number) => ({
|
||||
type: 'linear' as const,
|
||||
angle: 90,
|
||||
stops: Array.from({ length: n }, (_, i) => ({ at: i / (n - 1), color: '#112233' })),
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
const labels = { bar: 'bar', stop: (pos: number) => `stop ${pos}` };
|
||||
const three = render(
|
||||
<GradientStopBar paint={fade(3)} selected={1} onSelect={() => {}} onChange={onChange} labels={labels} />
|
||||
);
|
||||
fireEvent.keyDown(must(three.container.querySelectorAll('.st-stop')[1]), { key: 'Delete' });
|
||||
expect(onChange.mock.calls[0]?.[0].stops).toHaveLength(2);
|
||||
three.unmount();
|
||||
// The last two stops cannot go, and the key still does not reach the shape.
|
||||
const two = render(
|
||||
<GradientStopBar paint={fade(2)} selected={0} onSelect={() => {}} onChange={onChange} labels={labels} />
|
||||
);
|
||||
fireEvent.keyDown(must(two.container.querySelector('.st-stop')), { key: 'Backspace' });
|
||||
two.unmount();
|
||||
expect(elements().map((e) => e.id)).toEqual(['a']);
|
||||
|
||||
const dial = render(<AngleDial value={90} onChange={() => {}} label="Angle" />);
|
||||
fireEvent.keyDown(must(dial.container.querySelector('.st-dial')), { key: 'ArrowRight', shiftKey: true });
|
||||
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
|
||||
});
|
||||
|
||||
it("leaves the keys pressed in the assistant's window to it", () => {
|
||||
load([shape('a', 10, 10)]);
|
||||
store().select(['a']);
|
||||
store().copy(0, ['a']);
|
||||
draw();
|
||||
// As the Studio's assistant window does it: every key pressed in it stops there (StudioAssistantPanel.tsx).
|
||||
const chat = render(
|
||||
<aside className="st-assistant" onKeyDown={(e) => e.stopPropagation()}>
|
||||
<button type="button">Apply</button>
|
||||
</aside>
|
||||
);
|
||||
const button = must(chat.container.querySelector('button'));
|
||||
fireEvent.keyDown(button, { key: 'Delete' });
|
||||
fireEvent.keyDown(button, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(button, { key: 'v', ctrlKey: true });
|
||||
expect(elements().map((e) => e.id)).toEqual(['a']);
|
||||
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
|
||||
});
|
||||
|
||||
it('leaves the keys to a field being typed in', () => {
|
||||
load([shape('a', 10, 10)]);
|
||||
store().select(['a']);
|
||||
|
||||
@@ -235,7 +235,6 @@ export function StudioCanvas({
|
||||
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
|
||||
mask: null, frameStyle: 'none',
|
||||
} as BookElement)
|
||||
select([id])
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
|
||||
@@ -13,9 +13,10 @@ import type { PhotoFilter } from './photoFilter'
|
||||
* heading over the grid rather than as one control among several, and a long
|
||||
* entry name has somewhere to go.
|
||||
*
|
||||
* The popover is the top bar's format menu, hung from the left edge instead of
|
||||
* the right: the side panel is 236px wide and a menu anchored right would open
|
||||
* into the rail.
|
||||
* The popover is the top bar's format menu, hung from the start edge instead
|
||||
* of the end: the side panel is 236px wide and a menu anchored at the end would
|
||||
* open into the rail. A long entry name takes two lines in the chip, and the
|
||||
* menu shows every name whole.
|
||||
*/
|
||||
export function StudioContentFilter({
|
||||
source, filter, setFilter, admitted, pageEntryId, t, locale,
|
||||
@@ -79,7 +80,7 @@ export function StudioContentFilter({
|
||||
aria-expanded={open}
|
||||
title={t('journey.studio.filterPhotos')}
|
||||
>
|
||||
<span className="st-filter-name">{label}</span>
|
||||
<span className="st-filter-name" title={label}>{label}</span>
|
||||
<em>{admitted}</em>
|
||||
<ChevronDown size={12} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
@@ -31,10 +31,15 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
/**
|
||||
* One group of properties.
|
||||
*
|
||||
* `defaultOpen={false}` is for the groups you set once and rarely return to —
|
||||
* the frame's numbers, mostly. The state is per mounted section rather than
|
||||
* remembered: coming back to an element and finding its panel folded up
|
||||
* differently from last time is worse than opening one group again.
|
||||
* `defaultOpen={false}` is for the groups you set once and rarely return to:
|
||||
* the frame's numbers, the effects, the advanced settings of a kind. The
|
||||
* inspector opens such a group anyway when the element already uses it, so a
|
||||
* photo with a duotone shows its duotone and a plain one keeps it out of the
|
||||
* way (inspector/inspectorModel.ts says which groups are in use). The state
|
||||
* is per mounted section rather than remembered: the inspector mounts its
|
||||
* groups afresh for every selection, and coming back to an element and finding
|
||||
* its panel folded up differently from last time is worse than opening one
|
||||
* group again.
|
||||
*/
|
||||
export function Section({ label, children, defaultOpen = true, hint, badge }: {
|
||||
label: string
|
||||
@@ -51,15 +56,19 @@ export function Section({ label, children, defaultOpen = true, hint, badge }: {
|
||||
badge?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const body = useId()
|
||||
return (
|
||||
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
|
||||
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}>
|
||||
<span className="st-section-label">{label}</span>
|
||||
{badge && <span className="st-section-badge">{badge}</span>}
|
||||
<ChevronDown size={13} className="st-section-caret" />
|
||||
</button>
|
||||
<h3 className="st-section-title">
|
||||
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}
|
||||
aria-controls={open ? body : undefined}>
|
||||
<span className="st-section-label">{label}</span>
|
||||
{badge && <span className="st-section-badge">{badge}</span>}
|
||||
<ChevronDown size={13} className="st-section-caret" aria-hidden />
|
||||
</button>
|
||||
</h3>
|
||||
{open && (
|
||||
<div className="st-section-body">
|
||||
<div className="st-section-body" id={body}>
|
||||
{hint && <p className="st-hint st-section-hint">{hint}</p>}
|
||||
{children}
|
||||
</div>
|
||||
@@ -68,7 +77,17 @@ export function Section({ label, children, defaultOpen = true, hint, badge }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** A labelled line. The label sits left, the control fills what is left. */
|
||||
/**
|
||||
* A labelled line: the label first, the control after it.
|
||||
*
|
||||
* The two share the line while they fit and the control moves under the label
|
||||
* when they do not, so a long label ("Πεζά και κεφαλαία", "Átlátszatlanság")
|
||||
* pushes its control down a line instead of being cut or squeezing the control
|
||||
* to nothing. There is no column of a fixed width for the label to fit into;
|
||||
* short labels sit at a common minimum, so the controls of a group still start
|
||||
* at one edge. `wide` puts the control under the label always, for a control
|
||||
* that needs the whole width, like a row of swatches.
|
||||
*/
|
||||
export function Line({ label, children, wide = false }: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
bookShapeTakesEdge,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { NumField, Section } from './StudioControls';
|
||||
import { edgeInUse } from './inspector/inspectorModel';
|
||||
import { DEFAULT_EDGE_SIZE } from './shapeOutline';
|
||||
import { edgedPath } from './shapes-edges';
|
||||
import { NumField, Section } from './StudioControls';
|
||||
|
||||
/**
|
||||
* The edge of a shape or of a photo's cut: a row of tiles, each the edge drawn
|
||||
@@ -50,12 +51,8 @@ export function EdgeSection({
|
||||
// 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>
|
||||
)}
|
||||
<Section label={t('journey.studio.edgeStyle')} defaultOpen={edgeInUse(edge)}>
|
||||
{!takes && <p className="st-hint st-hint-before">{t('journey.studio.edgeUnavailable')}</p>}
|
||||
<div className="st-mini-shapes">
|
||||
{BOOK_EDGES.map((e) => (
|
||||
<button
|
||||
@@ -72,7 +69,7 @@ export function EdgeSection({
|
||||
))}
|
||||
</div>
|
||||
{takes && current !== 'straight' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.edgeSize')}
|
||||
value={edgeSize ?? DEFAULT_EDGE_SIZE}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { BookDocument } from '@trek/shared';
|
||||
import { BOOK_GRIDS, MAX_SPREAD_ELEMENTS, bookPageSetupSchema, normalizeBookDocument } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { StudioElementsPanel } from './StudioElementsPanel';
|
||||
import { searchIcons } from './iconLibrary';
|
||||
import { DIVIDER_PRESETS, LINE_PRESETS } from './linePresets';
|
||||
import { matchStickers } from './stickerLibrary';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-ELEMENTS-001 to FE-STUDIO-ELEMENTS-007: the Elements panel's six
|
||||
* tabs, each opening its own shelves, and each placing what it shows on the
|
||||
* open page, as long as the page has room for it.
|
||||
*/
|
||||
|
||||
const t = (k: string) => k;
|
||||
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
|
||||
const source = { entries: [], photos: [] };
|
||||
|
||||
const book = (): BookDocument =>
|
||||
normalizeBookDocument({
|
||||
version: 1,
|
||||
title: 'Porto',
|
||||
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||
spreads: [{ id: 'sp1', role: 'inner', entryId: null, background: null, elements: [], parked: [] }],
|
||||
});
|
||||
|
||||
const elements = () => useStudioStore.getState().doc?.spreads[0]?.elements ?? [];
|
||||
const last = () => elements()[elements().length - 1];
|
||||
const open = (name: string) => fireEvent.click(screen.getByRole('tab', { name }));
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
useStudioStore.getState().load(book());
|
||||
});
|
||||
|
||||
describe('the Elements panel', () => {
|
||||
it('FE-STUDIO-ELEMENTS-001: opens on Text, with the quotation mark among the plain styles', () => {
|
||||
render(<StudioElementsPanel page={page} t={t} source={source} />);
|
||||
const panel = screen.getByRole('tabpanel');
|
||||
expect(panel.getAttribute('aria-labelledby')).toBe(screen.getByRole('tab', { name: 'journey.studio.text' }).id);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.quoteMark' }));
|
||||
expect(last()).toMatchObject({ kind: 'text', text: '“', size: 46 });
|
||||
});
|
||||
|
||||
it('FE-STUDIO-ELEMENTS-002: puts the outline-only rectangle first among the basic shapes', () => {
|
||||
render(<StudioElementsPanel page={page} t={t} source={source} />);
|
||||
open('journey.studio.shapes');
|
||||
const outline = screen.getByRole('button', { name: 'journey.studio.shapeKind.outline' });
|
||||
expect(outline.closest('.st-shape-grid')?.firstElementChild).toBe(outline);
|
||||
fireEvent.click(outline);
|
||||
expect(last()).toMatchObject({ kind: 'shape', shape: 'rect', fill: null, stroke: '#141414' });
|
||||
});
|
||||
|
||||
it('FE-STUDIO-ELEMENTS-003: keeps the plain rule with the drawn lines and the dividers', () => {
|
||||
const { container } = render(<StudioElementsPanel page={page} t={t} source={source} />);
|
||||
open('journey.studio.lines');
|
||||
expect(container.querySelectorAll('.st-shape-btn')).toHaveLength(LINE_PRESETS.length + DIVIDER_PRESETS.length + 1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.shapeKind.line' }));
|
||||
expect(last()).toMatchObject({ kind: 'shape', shape: 'rect', frame: { h: 0.5 } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.linePreset.route' }));
|
||||
expect(last()).toMatchObject({ kind: 'line' });
|
||||
});
|
||||
|
||||
it('FE-STUDIO-ELEMENTS-004: offers frames in their styles and shapes, and the grids, in one tab', () => {
|
||||
render(<StudioElementsPanel page={page} t={t} source={source} />);
|
||||
open('journey.studio.frames');
|
||||
expect(screen.getByText('journey.studio.frameStyles')).toBeTruthy();
|
||||
expect(screen.getByText('journey.studio.frameShapes')).toBeTruthy();
|
||||
expect(screen.getByText('journey.studio.grids')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.polaroidFrame' }));
|
||||
expect(last()).toMatchObject({ kind: 'photo', photoId: null, frameStyle: 'polaroid' });
|
||||
const grid = BOOK_GRIDS[0];
|
||||
if (!grid) throw new Error('no grids');
|
||||
fireEvent.click(screen.getByRole('button', { name: grid.id }));
|
||||
expect(useStudioStore.getState().selection).toHaveLength(grid.cells.length);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-ELEMENTS-007: places nothing on a page that holds all it can, a frame or a whole grid', () => {
|
||||
const grid = BOOK_GRIDS.find((g) => g.cells.length > 1);
|
||||
if (!grid) throw new Error('no grid of several frames');
|
||||
const crowded = (n: number) =>
|
||||
normalizeBookDocument({
|
||||
...book(),
|
||||
spreads: [
|
||||
{
|
||||
id: 'sp1',
|
||||
role: 'inner',
|
||||
entryId: null,
|
||||
background: null,
|
||||
parked: [],
|
||||
elements: Array.from({ length: n }, (_, i) => ({
|
||||
id: `s${i}`,
|
||||
kind: 'shape',
|
||||
frame: { x: 0, y: 0, w: 5, h: 5 },
|
||||
})),
|
||||
},
|
||||
],
|
||||
});
|
||||
useStudioStore.getState().load(crowded(MAX_SPREAD_ELEMENTS));
|
||||
render(<StudioElementsPanel page={page} t={t} source={source} />);
|
||||
open('journey.studio.frames');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.polaroidFrame' }));
|
||||
expect(elements()).toHaveLength(MAX_SPREAD_ELEMENTS);
|
||||
expect(useStudioStore.getState().selection).toEqual([]);
|
||||
|
||||
useStudioStore.getState().load(crowded(MAX_SPREAD_ELEMENTS - 1));
|
||||
fireEvent.click(screen.getByRole('button', { name: grid.id }));
|
||||
expect(elements()).toHaveLength(MAX_SPREAD_ELEMENTS - 1);
|
||||
expect(useStudioStore.getState().canUndo()).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-ELEMENTS-005: searches the icons and the stickers above their grids', () => {
|
||||
render(<StudioElementsPanel page={page} t={t} source={source} />);
|
||||
open('journey.studio.icons');
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'journey.studio.searchIcons' }), {
|
||||
target: { value: 'compass' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Compass' }));
|
||||
expect(last()).toMatchObject({ kind: 'icon', name: 'Compass' });
|
||||
open('journey.studio.stickers');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.sticker.sun' }));
|
||||
expect(last()).toMatchObject({ kind: 'sticker', stickerId: 'sun' });
|
||||
});
|
||||
|
||||
it('FE-STUDIO-ELEMENTS-006: says in the search field how many stickers and icons the words match', () => {
|
||||
render(<StudioElementsPanel page={page} t={t} source={source} />);
|
||||
open('journey.studio.stickers');
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'journey.studio.searchStickers' }), {
|
||||
target: { value: 'sun' },
|
||||
});
|
||||
expect(screen.getByRole('status').textContent).toBe(String(matchStickers('sun', null, t).length));
|
||||
open('journey.studio.icons');
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'journey.studio.searchIcons' }), {
|
||||
target: { value: 'compass' },
|
||||
});
|
||||
expect(screen.getByRole('status').textContent).toBe(String(searchIcons('compass').length));
|
||||
// Spaces are not a search, so they leave the count out.
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'journey.studio.searchIcons' }), {
|
||||
target: { value: ' ' },
|
||||
});
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,47 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Minus, Quote, Search, Square, X } from 'lucide-react'
|
||||
import { useId, useState } from 'react'
|
||||
import { Frame, Minus, Quote, Shapes, Spline, Sparkles, Square, Sticker, Type, type LucideIcon } 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 { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, shapeName } from './shapes'
|
||||
import {
|
||||
BOOK_GRIDS as GRIDS, MAX_SPREAD_ELEMENTS, bookElementId, bookShapeSize, bookGridElements as gridElements,
|
||||
defaultBookGridBox as defaultGridBox,
|
||||
} from '@trek/shared'
|
||||
import { shapeTile } from './shapeOutline'
|
||||
import { StudioLineShelf } from './StudioLineShelf'
|
||||
import { PanelHead } from './StudioPanelHead'
|
||||
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
|
||||
import '../../styles/studio-elements.css'
|
||||
import { PanelHead, Shelf } from './StudioPanelHead'
|
||||
import { StudioTabPanel, StudioTabs } from './StudioTabs'
|
||||
import { StudioStickersPanel } from './StudioStickersPanel'
|
||||
import { StudioIconShelf } from './sidebar/StudioIconShelf'
|
||||
import { FRAME_STYLE_OPTIONS, type FrameStyle } from './studioOptions'
|
||||
import { StudioTextStyles } from './StudioTextStyles'
|
||||
import type { ShelfSource } from './textStylesModel'
|
||||
|
||||
/**
|
||||
* Everything you can add that does not come from the journey: type, shapes,
|
||||
* picture frames, photo grids and the icon set.
|
||||
* lines, picture frames and grids, the icon set and the stickers.
|
||||
*
|
||||
* Four tabs rather than four more entries on the rail. They are all the same
|
||||
* kind of answer to "what do I put on this page", and a rail long enough to
|
||||
* hold them separately stops being faster to hit than a word would be.
|
||||
* One panel with six tabs rather than six entries on the rail. They are all
|
||||
* the same kind of answer to "what do I put on this page", and a rail long
|
||||
* enough to hold them separately stops being faster to hit than a word would
|
||||
* be. The stickers were a rail section of their own until the rebuild; they
|
||||
* are one more kind of thing to add, so they sit here beside the icons.
|
||||
*/
|
||||
|
||||
// The book's own ids, the ones the layouts and the assistant mint (shared/src/book/book-ids.ts).
|
||||
const uid = bookElementId
|
||||
|
||||
/** How many icons the grid draws at a time. Two screens' worth at four across. */
|
||||
const ICON_PAGE = 120
|
||||
type Tab = 'text' | 'shapes' | 'lines' | 'frames' | 'icons' | 'stickers'
|
||||
|
||||
type FrameStyle = 'none' | 'polaroid' | 'white' | 'shadow' | 'film' | 'tape'
|
||||
/** The tabs, three to a row: a word under each icon, so the longest of them keeps its cell. */
|
||||
const TABS: readonly { id: Tab; icon: LucideIcon; labelKey: string }[] = [
|
||||
{ id: 'text', icon: Type, labelKey: 'journey.studio.text' },
|
||||
{ id: 'shapes', icon: Shapes, labelKey: 'journey.studio.shapes' },
|
||||
{ id: 'lines', icon: Spline, labelKey: 'journey.studio.lines' },
|
||||
{ id: 'frames', icon: Frame, labelKey: 'journey.studio.frames' },
|
||||
{ id: 'icons', icon: Sparkles, labelKey: 'journey.studio.icons' },
|
||||
{ id: 'stickers', icon: Sticker, labelKey: 'journey.studio.stickers' },
|
||||
]
|
||||
|
||||
/**
|
||||
* A shape as a tile.
|
||||
@@ -78,103 +92,34 @@ function FrameStyleGlyph({ style }: { style: FrameStyle }) {
|
||||
}}
|
||||
>
|
||||
<span className="st-frame-glyph-photo" style={{ inset: `${pad}px ${pad}px ${bottom}px ${pad}px` }} />
|
||||
{/* Both strips, because both is what gets placed — a preview showing one
|
||||
{/* Both strips, because both is what gets placed: a preview showing one
|
||||
made the second one look like a bug the first time it appeared. */}
|
||||
{style === 'tape' && <><em className="st-frame-glyph-tape" /><em className="st-frame-glyph-tape is-right" /></>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function IconCell({ name, onPick }: { name: string; onPick: (name: string) => void }) {
|
||||
const Icon = iconComponent(name)
|
||||
return (
|
||||
<button type="button"
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => onPick(name)}
|
||||
aria-label={name}
|
||||
title={iconLabel(name)}
|
||||
>
|
||||
<Icon strokeWidth={1.7} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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')
|
||||
const [iconQuery, setIconQuery] = useState('')
|
||||
/*
|
||||
* How much of the library is on screen.
|
||||
*
|
||||
* Fourteen hundred icons is fourteen hundred React components, each a
|
||||
* forwardRef around several SVG nodes, and rendering them all costs about a
|
||||
* second of blocked main thread on the first click of the tab — for a grid
|
||||
* whose first two rows are all anybody sees. So it grows as it is scrolled,
|
||||
* and a search resets it: the results of a search are short, and starting
|
||||
* them part-drawn would be wrong.
|
||||
*/
|
||||
const [iconLimit, setIconLimit] = useState(ICON_PAGE)
|
||||
const iconTail = useRef<HTMLDivElement>(null)
|
||||
/**
|
||||
* What every tab places, and where: centred on the page that is open, across
|
||||
* both pages of a spread or on the one page of a cover.
|
||||
*/
|
||||
function useElementAdders(page: BookPageSetup) {
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const select = useStudioStore(s => s.select)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
const single = !!spread && spread.role !== 'inner'
|
||||
const role = useStudioStore(s => s.doc?.spreads[s.activeSpread]?.role)
|
||||
const single = role !== undefined && role !== 'inner'
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
}
|
||||
|
||||
const addText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra: Partial<BookElement> = {}) =>
|
||||
const addText: AddText = (size, weight, sample, extra = {}) =>
|
||||
addElement(active, {
|
||||
id: uid('t'), kind: 'text', frame: centre(page.pageWidth * 0.7, size * 0.5 + 8),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
@@ -205,7 +150,7 @@ export function StudioElementsPanel({ page, t, source }: {
|
||||
: 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,
|
||||
// A rule is a shape too: a hairline box rather than its own element type,
|
||||
// so it moves, colours and snaps like everything else on the page.
|
||||
shape: isRule ? 'rect' : shape,
|
||||
fill: opts.outline ? null : '#141414',
|
||||
@@ -218,11 +163,9 @@ export function StudioElementsPanel({ page, t, source }: {
|
||||
}
|
||||
|
||||
/**
|
||||
* An icon, placed as a square.
|
||||
*
|
||||
* Square because that is what it is: unlike a shape, an icon keeps its
|
||||
* proportions inside whatever frame it is given, so a rectangle would only
|
||||
* pad it with air on two sides.
|
||||
* An icon, placed as a square. Unlike a shape, an icon keeps its proportions
|
||||
* inside whatever frame it is given, so a rectangle would only pad it with
|
||||
* air on two sides.
|
||||
*/
|
||||
const addIcon = (name: string) => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.18
|
||||
@@ -247,240 +190,238 @@ export function StudioElementsPanel({ page, t, source }: {
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
const matchedIcons = useMemo(() => searchIcons(iconQuery), [iconQuery])
|
||||
const shownIcons = useMemo(() => matchedIcons.slice(0, iconLimit), [matchedIcons, iconLimit])
|
||||
/** A block of empty frames, selected as a group so it moves and resizes as one thing straight away. */
|
||||
const addGrid = (grid: (typeof GRIDS)[number]) => {
|
||||
const els = gridElements(grid, defaultGridBox(page, single))
|
||||
// The page's own cap, as a duplicate keeps it: frames that would make the book unsaveable are not added.
|
||||
const on = useStudioStore.getState().doc?.spreads[active]
|
||||
if (!on) return
|
||||
if (on.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
|
||||
useStudioStore.getState().refuseFull()
|
||||
return
|
||||
}
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
|
||||
}))
|
||||
select(els.map(e => e.id))
|
||||
}
|
||||
|
||||
/*
|
||||
* Grow the grid when its foot comes into view.
|
||||
*
|
||||
* An observer rather than a scroll handler: the panel is the scroller, the
|
||||
* foot is the thing that matters, and asking the browser to tell us when they
|
||||
* meet costs nothing per frame.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const tail = iconTail.current
|
||||
if (!tail) return
|
||||
const io = new IntersectionObserver(entries => {
|
||||
if (entries.some(e => e.isIntersecting)) setIconLimit(n => n + ICON_PAGE)
|
||||
})
|
||||
io.observe(tail)
|
||||
return () => io.disconnect()
|
||||
}, [shownIcons.length, matchedIcons.length])
|
||||
return { active, single, addElement, addText, addShape, addIcon, addFrame, addGrid }
|
||||
}
|
||||
|
||||
type Adders = ReturnType<typeof useElementAdders>
|
||||
|
||||
/** The type tab: the plain styles and the big quotation mark, 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 (
|
||||
<>
|
||||
<Shelf label={t('journey.studio.text')}>
|
||||
<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>
|
||||
{/* A mark of type rather than a shape: it sets in the face the rest of the page is set in. */}
|
||||
<button type="button"
|
||||
className="st-tile"
|
||||
onClick={() => addText(46, 700, '“', { color: '#c9c2b4', leading: 0.9 })}
|
||||
>
|
||||
<Quote size={16} strokeWidth={1.6} aria-hidden />
|
||||
<span>{t('journey.studio.quoteMark')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
<StudioTextStyles page={page} source={source} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Every shape by its group, the outline-only rectangle first among the basic ones. */
|
||||
function ShapesTab({ addShape, t }: { addShape: Adders['addShape']; t: (k: string) => string }) {
|
||||
return (
|
||||
<>
|
||||
{SHAPE_GROUPS.map(group => (
|
||||
<Shelf key={group.id} label={t(`journey.studio.shapeGroup.${group.id}`)}>
|
||||
<div className="st-shape-grid">
|
||||
{group.id === 'basic' && (
|
||||
<button type="button"
|
||||
className="st-shape-btn"
|
||||
onClick={() => addShape('rect', { outline: true })}
|
||||
title={t('journey.studio.shapeKind.outline')}
|
||||
aria-label={t('journey.studio.shapeKind.outline')}
|
||||
>
|
||||
<Square size={20} strokeWidth={1.1} aria-hidden style={{ opacity: .5 }} />
|
||||
</button>
|
||||
)}
|
||||
{group.shapes.map(shape => (
|
||||
<button type="button"
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => addShape(shape)}
|
||||
title={shapeName(shape, t)}
|
||||
aria-label={shapeName(shape, t)}
|
||||
>
|
||||
<ShapeGlyph shape={shape} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Empty frames in their styles and shapes, and the grids that lay several of them out at once. */
|
||||
function FramesTab({ adders, t }: { adders: Adders; t: (k: string) => string }) {
|
||||
return (
|
||||
<>
|
||||
<Shelf label={t('journey.studio.frameStyles')}>
|
||||
<div className="st-shape-grid">
|
||||
{FRAME_STYLE_OPTIONS.map(({ value, labelKey }) => (
|
||||
<button type="button"
|
||||
key={value}
|
||||
className="st-shape-btn is-frame"
|
||||
onClick={() => adders.addFrame(null, value)}
|
||||
title={t(labelKey)}
|
||||
aria-label={t(labelKey)}
|
||||
>
|
||||
<FrameStyleGlyph style={value} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
<Shelf label={t('journey.studio.frameShapes')} hint={t('journey.studio.frameHint')}>
|
||||
<div className="st-shape-grid">
|
||||
{FRAME_SHAPES.map(shape => (
|
||||
<button type="button"
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => adders.addFrame(shape === 'rect' ? null : shape)}
|
||||
title={shapeName(shape, t)}
|
||||
aria-label={shapeName(shape, t)}
|
||||
>
|
||||
<ShapeGlyph shape={shape} photo />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
<Shelf label={t('journey.studio.grids')} hint={t('journey.studio.gridHint')}>
|
||||
<div className="st-grid-grid">
|
||||
{GRIDS.map(grid => (
|
||||
<button
|
||||
type="button"
|
||||
key={grid.id}
|
||||
className="st-grid-btn"
|
||||
onClick={() => adders.addGrid(grid)}
|
||||
aria-label={grid.id}
|
||||
title={`${grid.cells.length}`}
|
||||
>
|
||||
{/* The arrangement itself: "hero-two" means nothing until you
|
||||
have seen it, and a diagram is faster to read than a name. */}
|
||||
<span className="st-grid-preview">
|
||||
{grid.cells.map((cell, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
left: `${cell.x * 100}%`,
|
||||
top: `${cell.y * 100}%`,
|
||||
width: `${cell.w * 100}%`,
|
||||
height: `${cell.h * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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<Tab>('text')
|
||||
const tabsId = useId()
|
||||
const adders = useElementAdders(page)
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<button type="button" className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}>
|
||||
{t('journey.studio.shapes')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'frames' ? 'is-on' : ''} onClick={() => setTab('frames')}>
|
||||
{t('journey.studio.frames')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'grids' ? 'is-on' : ''} onClick={() => setTab('grids')}>
|
||||
{t('journey.studio.grids')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'icons' ? 'is-on' : ''} onClick={() => setTab('icons')}>
|
||||
{t('journey.studio.icons')}
|
||||
</button>
|
||||
</div>
|
||||
<StudioTabs<Tab>
|
||||
idBase={tabsId}
|
||||
label={t('journey.studio.elements')}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
columns={3}
|
||||
tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))}
|
||||
/>
|
||||
|
||||
<div className="st-panel-scroll">
|
||||
<StudioTabPanel idBase={tabsId} value={tab} className="st-tab-body">
|
||||
{/*
|
||||
Type first: the plain styles, then the lockups, set titles and marks
|
||||
drawn with the book's own words (StudioTextStyles.tsx).
|
||||
The icons and the stickers carry a search of their own, which sits
|
||||
above their scrolling grid like the Content panel's above its own;
|
||||
the other tabs are short shelves and scroll as they are.
|
||||
*/}
|
||||
{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.lines')}</div>
|
||||
<div className="st-shape-grid">
|
||||
<button type="button" className="st-shape-btn" onClick={() => addShape('line')} title={t('journey.studio.shapeKind.line')}>
|
||||
<Minus size={20} strokeWidth={2} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-shape-btn"
|
||||
onClick={() => addShape('rect', { outline: true })}
|
||||
title={t('journey.studio.shapeKind.outline')}
|
||||
>
|
||||
<Square size={20} strokeWidth={1.1} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-shape-btn"
|
||||
onClick={() => addText(46, 700, '“', { color: '#c9c2b4', leading: 0.9 })}
|
||||
title={t('journey.studio.quoteMark')}
|
||||
>
|
||||
<Quote size={20} strokeWidth={1.6} />
|
||||
</button>
|
||||
</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>
|
||||
<div className="st-shape-grid">
|
||||
{group.shapes.map(shape => (
|
||||
<button type="button"
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => addShape(shape)}
|
||||
aria-label={shape}
|
||||
>
|
||||
<ShapeGlyph shape={shape} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'frames' && (
|
||||
<>
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.frameStyles')}</div>
|
||||
<div className="st-shape-grid">
|
||||
{([
|
||||
['none', 'plainFrame'],
|
||||
['polaroid', 'polaroidFrame'],
|
||||
['white', 'whiteFrame'],
|
||||
['shadow', 'shadowFrame'],
|
||||
['film', 'filmFrame'],
|
||||
['tape', 'tapeFrame'],
|
||||
] as [FrameStyle, string][]).map(([style, key]) => (
|
||||
{tab === 'icons' ? (
|
||||
<StudioIconShelf onPick={adders.addIcon} t={t} />
|
||||
) : tab === 'stickers' ? (
|
||||
<StudioStickersPanel page={page} t={t} />
|
||||
) : (
|
||||
<div className="st-panel-scroll">
|
||||
{tab === 'text' && <TextTab page={page} source={source} t={t} addText={adders.addText} />}
|
||||
{tab === 'shapes' && <ShapesTab addShape={adders.addShape} t={t} />}
|
||||
{tab === 'lines' && (
|
||||
<StudioLineShelf
|
||||
page={page}
|
||||
single={adders.single}
|
||||
onAdd={el => adders.addElement(adders.active, el)}
|
||||
t={t}
|
||||
lead={(
|
||||
<button type="button"
|
||||
key={style}
|
||||
className="st-shape-btn is-frame"
|
||||
onClick={() => addFrame(null, style)}
|
||||
title={t(`journey.studio.${key}`)}
|
||||
aria-label={t(`journey.studio.${key}`)}
|
||||
className="st-shape-btn"
|
||||
onClick={() => adders.addShape('line')}
|
||||
title={t('journey.studio.shapeKind.line')}
|
||||
aria-label={t('journey.studio.shapeKind.line')}
|
||||
>
|
||||
<FrameStyleGlyph style={style} />
|
||||
<Minus size={20} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.frameShapes')}</div>
|
||||
<div className="st-shape-grid">
|
||||
{FRAME_SHAPES.map(shape => (
|
||||
<button type="button"
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => addFrame(shape === 'rect' ? null : shape)}
|
||||
aria-label={shape}
|
||||
>
|
||||
<ShapeGlyph shape={shape} photo />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.frameHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'grids' && (
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.grids')}</div>
|
||||
<div className="st-grid-grid">
|
||||
{GRIDS.map(grid => (
|
||||
<button
|
||||
type="button"
|
||||
key={grid.id}
|
||||
className="st-grid-btn"
|
||||
onClick={() => {
|
||||
const els = gridElements(grid, defaultGridBox(page, single))
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) =>
|
||||
(i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
|
||||
}))
|
||||
// Selected as a group, so the block can be moved or resized
|
||||
// as one thing straight away rather than cell by cell.
|
||||
select(els.map(e => e.id))
|
||||
}}
|
||||
aria-label={grid.id}
|
||||
title={`${grid.cells.length}`}
|
||||
>
|
||||
{/* The arrangement itself: "hero-two" means nothing until you
|
||||
have seen it, and a diagram is faster to read than a name. */}
|
||||
<span className="st-grid-preview">
|
||||
{grid.cells.map((cell, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
left: `${cell.x * 100}%`,
|
||||
top: `${cell.y * 100}%`,
|
||||
width: `${cell.w * 100}%`,
|
||||
height: `${cell.h * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.gridHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'icons' && (
|
||||
<div className="st-section">
|
||||
{/*
|
||||
The search sits inside the scroller rather than above the tab
|
||||
strip, unlike the Content panel's: that one filters both of its
|
||||
tabs, this one belongs to a single tab, and hanging it above the
|
||||
strip would make the strip move when you switch away from it.
|
||||
*/}
|
||||
<div className="st-search" style={{ margin: '0 0 10px' }}>
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={iconQuery}
|
||||
onChange={e => { setIconQuery(e.target.value); setIconLimit(ICON_PAGE) }}
|
||||
placeholder={t('journey.studio.searchIcons')}
|
||||
aria-label={t('journey.studio.searchIcons')}
|
||||
spellCheck={false}
|
||||
)}
|
||||
/>
|
||||
{iconQuery && (
|
||||
<button type="button" onClick={() => { setIconQuery(''); setIconLimit(ICON_PAGE) }} aria-label={t('common.clear')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* The shelf, until somebody searches. */}
|
||||
{!iconQuery && (
|
||||
<>
|
||||
<div className="st-section-label">{t('journey.studio.iconsForTravel')}</div>
|
||||
<div className="st-shape-grid" style={{ marginBottom: 14 }}>
|
||||
{FEATURED_ICONS.map(name => (
|
||||
<IconCell key={name} name={name} onPick={addIcon} />
|
||||
))}
|
||||
</div>
|
||||
<div className="st-section-label">{t('journey.studio.iconsAll')}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="st-shape-grid">
|
||||
{shownIcons.map(name => (
|
||||
<IconCell key={name} name={name} onPick={addIcon} />
|
||||
))}
|
||||
</div>
|
||||
{/* What the observer below watches for. */}
|
||||
{shownIcons.length < matchedIcons.length && <div ref={iconTail} style={{ height: 1 }} />}
|
||||
{!matchedIcons.length && (
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.noMatches')}</p>
|
||||
)}
|
||||
{tab === 'frames' && <FramesTab adders={adders} t={t} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StudioTabPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { StudioHeader } from './StudioHeader';
|
||||
import type { Studio } from './StudioShell';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-HEAD-001 to FE-STUDIO-HEAD-007: the Studio's head band, with the
|
||||
* hook's answer stubbed to what the band reads.
|
||||
*/
|
||||
|
||||
function studio(over: Partial<Studio> = {}): Studio {
|
||||
return {
|
||||
t: (key: string) => key,
|
||||
journey: { title: 'Japan 2026' },
|
||||
coverUrl: '/uploads/cover.jpg',
|
||||
saveState: { status: 'idle' },
|
||||
peers: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
canEdit: true,
|
||||
activeSpread: 0,
|
||||
doc: { spreads: [] },
|
||||
preset: 'square-210',
|
||||
page: { pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||
canRelayoutSpread: true,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
close: vi.fn(),
|
||||
setPreset: vi.fn(),
|
||||
setPageSize: vi.fn(),
|
||||
setPageEdge: vi.fn(),
|
||||
relayoutCurrentSpread: vi.fn(),
|
||||
relayoutBook: vi.fn(),
|
||||
loadDoc: vi.fn(),
|
||||
acceptTheirs: vi.fn(),
|
||||
keepMine: vi.fn(),
|
||||
saveNow: vi.fn(),
|
||||
...over,
|
||||
} as unknown as Studio;
|
||||
}
|
||||
|
||||
const noAssistant = { available: false } as unknown as StudioAssistant;
|
||||
|
||||
function head(s = studio(), onExport = vi.fn()) {
|
||||
render(<StudioHeader s={s} onExport={onExport} assistant={noAssistant} />);
|
||||
return { s, onExport };
|
||||
}
|
||||
|
||||
describe('StudioHeader', () => {
|
||||
it("FE-STUDIO-HEAD-001: names the journey under the Studio's mark, with its cover as the tile", () => {
|
||||
head();
|
||||
expect(screen.getByRole('heading', { name: 'Japan 2026' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'TREK Studio' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.st-head-tile img')?.getAttribute('src')).toBe('/uploads/cover.jpg');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-002: the round button closes the Studio, export hands over', () => {
|
||||
const { s, onExport } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.backToJourney' }));
|
||||
expect(s.close).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.export/ }));
|
||||
expect(onExport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-003: undo runs, redo waits for something to redo, paste for something copied', () => {
|
||||
const { s } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.undo' }));
|
||||
expect(s.undo).toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.redo' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.paste' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-004: the format chip names the format and opens the list, a pick sets it', () => {
|
||||
const { s } = head();
|
||||
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
|
||||
expect(chip).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(chip);
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toBeGreaterThan(1);
|
||||
fireEvent.click(options[options.length - 1]);
|
||||
expect(s.setPreset).toHaveBeenCalled();
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-005: a custom format reads its size, and Escape or a press outside closes the list', () => {
|
||||
head(
|
||||
studio({ preset: 'custom', page: { pageWidth: 180.4, pageHeight: 240, bleed: 3, safe: 5 } } as Partial<Studio>)
|
||||
);
|
||||
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
|
||||
expect(chip).toHaveTextContent('180 × 240 mm');
|
||||
fireEvent.click(chip);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
fireEvent.click(chip);
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-006: auto layout offers this spread and the whole book', () => {
|
||||
const { s } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^journey\.studio\.relayoutSpread/ }));
|
||||
expect(s.relayoutCurrentSpread).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^journey\.studio\.relayoutBook/ }));
|
||||
expect(s.relayoutBook).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-007: with something copied, paste puts it on the page on screen', () => {
|
||||
const paste = vi.fn();
|
||||
useStudioStore.setState({ clipboard: { elements: [] } as never, paste } as never);
|
||||
head(studio({ activeSpread: 2 } as Partial<Studio>));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.paste' }));
|
||||
expect(paste).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import { Check, ChevronDown, ClipboardPaste, Download, Redo2, Sparkles, Undo2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-head.css';
|
||||
import HelpButton from '../Help/HelpButton';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets';
|
||||
import { PeerBadges } from './PeerBadges';
|
||||
import { SaveIndicator } from './SaveIndicator';
|
||||
import { StudioAssistantToggle } from './StudioAssistantPanel';
|
||||
import type { Studio } from './StudioShell';
|
||||
import { StudioWordmark } from './StudioWordmark';
|
||||
import { TrimField } from './TrimField';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* The Studio's head band, in the shape every TREK dialog has: a raised tile on
|
||||
* the left, the Studio's mark above the title, the title, a line under it, and
|
||||
* the close button at the far end.
|
||||
*
|
||||
* The tile is the journey's cover and the title its name, so it is always clear
|
||||
* whose book this is. The tools on the right are grouped by what they do:
|
||||
* who else is here and whether the book is saved, what the book is printed as, editing (undo, redo, paste), auto
|
||||
* layout, the assistant, help, and getting the book out. Every piece of
|
||||
* the row is one raised tile of one height, export alone in the accent. How
|
||||
* the page is looked at, the book view, lives with the zoom under the page.
|
||||
*/
|
||||
export function StudioHeader({
|
||||
s,
|
||||
onExport,
|
||||
assistant,
|
||||
}: {
|
||||
s: Studio;
|
||||
onExport: () => void;
|
||||
assistant: StudioAssistant;
|
||||
}) {
|
||||
return (
|
||||
<header className="st-bar st-head">
|
||||
<span className="st-head-tile">{s.coverUrl ? <img src={s.coverUrl} alt="" /> : null}</span>
|
||||
|
||||
<div className="st-head-text">
|
||||
<div className="st-head-eyebrow">
|
||||
<StudioWordmark style={{ height: 17 }} />
|
||||
</div>
|
||||
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
|
||||
</div>
|
||||
|
||||
<div className="st-head-tools">
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
{/* Whether the book is safe, as a tile beside who else is here: both say how the book stands. */}
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => {
|
||||
void s.saveNow();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="st-head-group">
|
||||
<FormatPicker s={s} />
|
||||
</div>
|
||||
|
||||
<div className="st-head-group">
|
||||
<IconTool label={s.t('journey.studio.undo')} disabled={!s.canUndo} onClick={s.undo}>
|
||||
<Undo2 size={15} />
|
||||
</IconTool>
|
||||
<IconTool label={s.t('journey.studio.redo')} disabled={!s.canRedo} onClick={s.redo}>
|
||||
<Redo2 size={15} />
|
||||
</IconTool>
|
||||
<PasteButton s={s} />
|
||||
</div>
|
||||
|
||||
<div className="st-head-group">
|
||||
<AutoLayoutButton s={s} />
|
||||
</div>
|
||||
|
||||
{/* The book's assistant, a tile of its own like help; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon st-head-solo" />
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-primary st-head-solo"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
|
||||
<Tooltip label={s.t('journey.studio.backToJourney')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={s.close}
|
||||
className="st-head-close"
|
||||
aria-label={s.t('journey.studio.backToJourney')}
|
||||
>
|
||||
<X size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTool({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button type="button" className="st-tool is-icon" disabled={disabled} onClick={onClick} aria-label={label}>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a popover on a pointer press outside it and on Escape. Escape is
|
||||
* swallowed, or the shell's own handler would close Studio itself.
|
||||
*/
|
||||
function useDismiss(box: RefObject<HTMLElement | null>, open: boolean, setOpen: (open: boolean) => void) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
}, [box, open, setOpen]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
|
||||
* because it is the same kind of thing, and there so the keyboard shortcut is
|
||||
* not the only way to find it.
|
||||
*/
|
||||
function PasteButton({ s }: { s: Studio }) {
|
||||
const clipboard = useStudioStore((st) => st.clipboard);
|
||||
const paste = useStudioStore((st) => st.paste);
|
||||
const label = s.t('journey.studio.paste');
|
||||
return (
|
||||
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!clipboard || !s.canEdit}
|
||||
onClick={() => paste(s.activeSpread)}
|
||||
aria-label={label}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out again: this spread, or the whole book.
|
||||
*
|
||||
* A split button rather than two: they are the same action at two scopes, and
|
||||
* the spread is the one you want nine times out of ten. Rebuilding the whole
|
||||
* book is behind the chevron, where it cannot be hit by accident, and both are
|
||||
* ordinary undo steps.
|
||||
*/
|
||||
function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useDismiss(box, open, setOpen);
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={s.t('journey.studio.autoLayout')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
|
||||
<ChevronDown size={13} style={{ opacity: 0.5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!s.canRelayoutSpread}
|
||||
onClick={() => {
|
||||
s.relayoutCurrentSpread();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
|
||||
<span className="st-menu-dim">
|
||||
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
s.relayoutBook();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
|
||||
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The format, as a tool of the head row: it says what the book is printed as,
|
||||
* and opening it changes that.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
* runs off the bottom of the screen. This is a small popover instead, themed,
|
||||
* anchored under the chip, and it shows each format's proportions rather than
|
||||
* only naming them.
|
||||
*/
|
||||
function FormatPicker({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useDismiss(box, open, setOpen);
|
||||
|
||||
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210'];
|
||||
const width = s.preset === 'custom' ? s.page.pageWidth : active.pageWidthMm;
|
||||
const height = s.preset === 'custom' ? s.page.pageHeight : active.pageHeightMm;
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={s.t('journey.studio.format')}
|
||||
>
|
||||
<span
|
||||
className="st-head-chip-shape"
|
||||
style={
|
||||
width >= height
|
||||
? { width: 12, height: Math.max(6, Math.round((12 * height) / width)) }
|
||||
: { height: 12, width: Math.max(6, Math.round((12 * width) / height)) }
|
||||
}
|
||||
/>
|
||||
<span className="st-tool-label">
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={13} style={{ opacity: 0.5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map((id) => {
|
||||
const p = PAGE_PRESETS[id];
|
||||
const on = id === s.preset;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
s.setPreset(id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-tile">
|
||||
<span
|
||||
className="st-menu-shape"
|
||||
style={
|
||||
p.pageWidthMm >= p.pageHeightMm
|
||||
? { width: 24, height: Math.round((24 * p.pageHeightMm) / p.pageWidthMm) }
|
||||
: { height: 24, width: Math.round((24 * p.pageWidthMm) / p.pageHeightMm) }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t(p.labelKey)}</span>
|
||||
<span className="st-menu-dim">
|
||||
{p.pageWidthMm} × {p.pageHeightMm} mm
|
||||
</span>
|
||||
</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/*
|
||||
The two fields live under the list rather than behind a "custom"
|
||||
entry that opens a second popover: picking the custom row and then
|
||||
typing into the fields it reveals is two steps for one decision, and
|
||||
typing into them *is* picking custom; the hook switches the preset
|
||||
for you.
|
||||
*/}
|
||||
<div className="st-menu-sep" />
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.width')}
|
||||
value={s.page.pageWidth}
|
||||
onCommit={(v) => s.setPageSize('w', v)}
|
||||
/>
|
||||
<span className="st-menu-times">×</span>
|
||||
<TrimField
|
||||
label={s.t('journey.studio.height')}
|
||||
value={s.page.pageHeight}
|
||||
onCommit={(v) => s.setPageSize('h', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
What the press takes off, and what it must not print into. Under the
|
||||
trim size because they answer the same question, how big is this book
|
||||
really, and because a vendor states all three in one breath.
|
||||
*/}
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.bleed')}
|
||||
value={s.page.bleed}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onCommit={(v) => s.setPageEdge('bleed', v)}
|
||||
/>
|
||||
<span className="st-menu-times" />
|
||||
<TrimField
|
||||
label={s.t('journey.studio.safeArea')}
|
||||
value={s.page.safe}
|
||||
min={0}
|
||||
max={40}
|
||||
step={0.5}
|
||||
onCommit={(v) => s.setPageEdge('safe', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,25 @@
|
||||
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared'
|
||||
import {
|
||||
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp,
|
||||
ChevronsDown, ChevronsUp, ClipboardCopy, Copy, Italic, Lock, Trash2, Unlock,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import type { BookElement, BookPageNumbers, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
|
||||
import { withThemeLinks } from '@trek/shared'
|
||||
import { ClipboardCopy, Copy, Lock, Trash2, Unlock } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { photoSrc } from './bookRender'
|
||||
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
|
||||
import { ShapeGlyph } from './StudioElementsPanel'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import '../../styles/studio-panels.css'
|
||||
import { PanelHead } from './StudioPanelHead'
|
||||
import { selectionTitle } from './studioLayerRows'
|
||||
import { StudioArrangeSection } from './StudioArrangeSection'
|
||||
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 { 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 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
|
||||
import { StudioDocumentInspector } from './inspector/StudioDocumentInspector'
|
||||
import { StudioPositionSection } from './inspector/StudioPositionSection'
|
||||
import { StudioTextInspector } from './inspector/StudioTextInspector'
|
||||
import { StudioPhotoInspector } from './inspector/StudioPhotoInspector'
|
||||
import { StudioShapeInspector } from './inspector/StudioShapeInspector'
|
||||
import { StudioIconInspector } from './inspector/StudioIconInspector'
|
||||
import { selectionKey } from './inspector/inspectorModel'
|
||||
import type { RailSection } from './sidebar/railSections'
|
||||
import type { JourneySource } from './StudioSidebar'
|
||||
|
||||
/**
|
||||
* Properties of whatever is selected.
|
||||
@@ -42,24 +31,27 @@ const ICON_CHOICES = 60
|
||||
* out reads as a settings dialog, not as a tool.
|
||||
*
|
||||
* The order is the same for every kind, because the panel is used by muscle
|
||||
* memory more than by reading: **what it says** first, then **how it looks**,
|
||||
* then **where it sits**, then **what is in front of what**. A photo's crop and
|
||||
* a mark's words are the same step in that sequence, so they sit in the same
|
||||
* place on the panel.
|
||||
* memory more than by reading:
|
||||
*
|
||||
* The frame's numbers are folded away by default. They are the least-used group
|
||||
* in the panel — dragging is how anyone actually positions an element — and
|
||||
* four number fields left open at the top pushed everything worth looking at
|
||||
* below the fold.
|
||||
* 1. **what it is and what it says**: a text's words, a photo's crop, a
|
||||
* shape's outline, a sticker's colours, a map's scope and framing;
|
||||
* 2. **where it sits**: the position and size, folded unless the element is
|
||||
* turned or faded (or is a sticker, which is turned as a matter of course),
|
||||
* and for several at once their angle and opacity without the frame;
|
||||
* 3. **how it looks**: type, colour, fill, frame;
|
||||
* 4. **the effects**: folded, and open on an element that uses them;
|
||||
* 5. **arrange**: lining up, stacking, grouping, the style clipboard.
|
||||
*
|
||||
* ── What is not here any more ────────────────────────────────────────────
|
||||
* Every group mounts afresh for each selection (inspector/inspectorModel.ts),
|
||||
* so a folded group opens for the element that uses it and closes again for
|
||||
* the next one, and nothing is remembered behind the person's back.
|
||||
*
|
||||
* Duplicate, lock and delete are in the panel head rather than in a section of
|
||||
* their own: they act on the element rather than describing it, and a group
|
||||
* called "Arrange" that held two of those next to the stacking buttons was the
|
||||
* one group in the panel whose name did not say what was inside it.
|
||||
* Duplicate, lock and delete are in the panel head rather than in a group of
|
||||
* their own: they act on the element rather than describing it.
|
||||
*/
|
||||
|
||||
type Set = (patch: Partial<BookElement>) => void
|
||||
|
||||
export function StudioInspector({
|
||||
spreadIndex,
|
||||
page,
|
||||
@@ -69,6 +61,7 @@ export function StudioInspector({
|
||||
setPageNumbers,
|
||||
t,
|
||||
locale,
|
||||
onOpenPanel,
|
||||
}: {
|
||||
spreadIndex: number
|
||||
page: BookPageSetup
|
||||
@@ -81,126 +74,35 @@ export function StudioInspector({
|
||||
source: JourneySource
|
||||
t: (k: string) => string
|
||||
locale: string
|
||||
/** Opens a panel of the rail on the other side: the Theme panel, or the Layers panel beside the selection. */
|
||||
onOpenPanel?: (section: RailSection) => void
|
||||
}) {
|
||||
const [iconQuery, setIconQuery] = useState('')
|
||||
/*
|
||||
* A page of results, memoised because the inspector re-renders on every
|
||||
* pointer move of a drag and the library is fourteen hundred names long.
|
||||
*/
|
||||
const iconChoices = useMemo(() => searchIcons(iconQuery).slice(0, ICON_CHOICES), [iconQuery])
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const selection = useStudioStore(s => s.selection)
|
||||
const update = useStudioStore(s => s.updateElement)
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const raise = useStudioStore(s => s.raise)
|
||||
const duplicate = useStudioStore(s => s.duplicate)
|
||||
const copy = useStudioStore(s => s.copy)
|
||||
const removeElements = useStudioStore(s => s.removeElements)
|
||||
|
||||
const spread = doc?.spreads[spreadIndex]
|
||||
const sel = spread?.elements.filter(e => selection.includes(e.id)) ?? []
|
||||
const el = sel[0]
|
||||
|
||||
if (!sel.length) {
|
||||
/*
|
||||
* Nothing selected, so this is where the *book's* own settings belong.
|
||||
* The panel was showing one line of hint text and nothing else, and page
|
||||
* numbers have nowhere else to go: they are not a property of any element,
|
||||
* because the number a page carries depends on where its spread sits.
|
||||
*/
|
||||
const folios = page.pageNumbers
|
||||
if (!el) {
|
||||
return (
|
||||
<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')}
|
||||
on={folios.show}
|
||||
onToggle={() => setPageNumbers({ show: !folios.show })}
|
||||
/>
|
||||
|
||||
{folios.show && (
|
||||
<>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.position')}>
|
||||
<Choice
|
||||
value={folios.position}
|
||||
options={(['outer', 'inner', 'centre'] as const).map(pos => ({
|
||||
value: pos, label: t(`journey.studio.folio.${pos}`),
|
||||
}))}
|
||||
onPick={position => setPageNumbers({ position })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.folioStart')}
|
||||
value={folios.startAt}
|
||||
min={0}
|
||||
max={9999}
|
||||
step={1}
|
||||
onChange={v => setPageNumbers({ startAt: Math.round(v) })}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.size')}
|
||||
value={folios.size}
|
||||
min={4}
|
||||
max={48}
|
||||
step={0.5}
|
||||
unit="pt"
|
||||
onChange={v => setPageNumbers({ size: v })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.folioMargin')}
|
||||
value={folios.margin}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={v => setPageNumbers({ margin: v })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Switch
|
||||
label={t('journey.studio.folioAuto')}
|
||||
on={folios.autoColor}
|
||||
onToggle={() => setPageNumbers({ autoColor: !folios.autoColor })}
|
||||
/>
|
||||
</div>
|
||||
{/* Picking a colour is the other way to turn automatic off, so
|
||||
the row stays reachable and simply reads as inactive. */}
|
||||
<div style={{ marginTop: 10, opacity: folios.autoColor ? 0.45 : 1 }}>
|
||||
<Line label={t('journey.studio.colour')} wide>
|
||||
<Swatches
|
||||
value={folios.color}
|
||||
onPick={c => setPageNumbers({ color: c, autoColor: false })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<p className="st-hint" style={{ paddingTop: 10 }}>
|
||||
{t('journey.studio.inspectorEmpty')}
|
||||
</p>
|
||||
<p className="st-hint" style={{ paddingTop: 6 }}>
|
||||
{t('journey.studio.pageHint')} {page.pageWidth} × {page.pageHeight} mm
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
<StudioDocumentInspector
|
||||
spreadIndex={spreadIndex}
|
||||
page={page}
|
||||
source={source}
|
||||
setPageNumbers={setPageNumbers}
|
||||
onOpenTheme={onOpenPanel && (() => onOpenPanel('theme'))}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const el = sel[0]
|
||||
const one = sel.length === 1
|
||||
|
||||
// A property edit is one undo step on its own; only drags open a gesture.
|
||||
const set = (patch: Partial<BookElement>) => commit(d => ({
|
||||
const set: Set = patch => commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
|
||||
...sp,
|
||||
@@ -209,581 +111,130 @@ export function StudioInspector({
|
||||
})),
|
||||
}))
|
||||
|
||||
const live = (patch: Partial<BookElement>) => update(spreadIndex, el.id, patch)
|
||||
|
||||
/**
|
||||
* Stretch the picture over the page it is on, or over both pages, bleed
|
||||
* included.
|
||||
*
|
||||
* One commit rather than `set` followed by `raise`: the frame, the rotation
|
||||
* put back to zero (a turned picture leaves bare corners), the cover crop and
|
||||
* the move to the back are one press, and one press comes back with one
|
||||
* undo. Which page is the one under the frame's centre; a cover has only
|
||||
* the one.
|
||||
*/
|
||||
const fill = (scope: 'page' | 'spread') => commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => {
|
||||
if (i !== spreadIndex) return sp
|
||||
const bleed = page.bleed
|
||||
const onRight = sp.role === 'inner' && scope === 'page'
|
||||
&& el.frame.x + el.frame.w / 2 >= page.pageWidth
|
||||
const frame = {
|
||||
x: onRight ? page.pageWidth - bleed : -bleed,
|
||||
y: -bleed,
|
||||
w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed,
|
||||
h: page.pageHeight + 2 * bleed,
|
||||
}
|
||||
const filled = { ...el, frame, rotation: 0, fit: 'cover' } as BookElement
|
||||
return { ...sp, elements: [filled, ...sp.elements.filter(e => e.id !== el.id)] }
|
||||
}),
|
||||
}))
|
||||
const live: Set = patch => update(spreadIndex, el.id, patch)
|
||||
|
||||
return (
|
||||
<aside className="st-panel st-inspector">
|
||||
{/*
|
||||
The head says what is selected and carries the three things you do TO
|
||||
it. It used to say only the kind, in the same small capitals as every
|
||||
section label below it, which left the panel with no top.
|
||||
The head says what is selected and carries the things you do TO it, in
|
||||
the head every panel opens on.
|
||||
*/}
|
||||
<div className="st-panel-head st-inspector-head">
|
||||
<span className="st-what">
|
||||
{one ? t(`journey.studio.kind.${el.kind}`) : selectionTitle(spread, selection, t)}
|
||||
{sel.length > 1 && <em>{sel.length}</em>}
|
||||
</span>
|
||||
<span className="st-head-acts">
|
||||
<Tooltip label={t('journey.studio.copyToPage')}>
|
||||
<button type="button"
|
||||
className="st-act"
|
||||
onClick={() => copy(spreadIndex, selection)}
|
||||
aria-label={t('journey.studio.copyToPage')}
|
||||
>
|
||||
<ClipboardCopy size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button type="button"
|
||||
className="st-act"
|
||||
onClick={() => duplicate(spreadIndex, selection)}
|
||||
title={t('journey.studio.duplicate')}
|
||||
aria-label={t('journey.studio.duplicate')}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className={`st-act ${el.locked ? 'is-on' : ''}`}
|
||||
onClick={() => set({ locked: !el.locked })}
|
||||
title={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
|
||||
aria-label={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
|
||||
aria-pressed={el.locked}
|
||||
>
|
||||
{el.locked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-act is-danger"
|
||||
onClick={() => removeElements(spreadIndex, selection)}
|
||||
title={t('journey.studio.delete')}
|
||||
aria-label={t('journey.studio.delete')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<PanelHead
|
||||
label={one ? t(`journey.studio.kind.${el.kind}`) : selectionTitle(spread, selection, t)}
|
||||
count={sel.length > 1 ? sel.length : undefined}
|
||||
actions={<SelectionActions spreadIndex={spreadIndex} el={el} selection={selection} set={set} t={t} />}
|
||||
/>
|
||||
|
||||
<div className="st-panel-scroll">
|
||||
{el.kind === 'text' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.text')}>
|
||||
<textarea
|
||||
className="st-input st-textarea"
|
||||
value={el.text}
|
||||
rows={5}
|
||||
onChange={e => live({ text: e.target.value, overridden: true })}
|
||||
onBlur={e => set({ text: e.target.value, overridden: true })}
|
||||
/>
|
||||
{el.binding && !el.overridden && (
|
||||
<p className="st-hint" style={{ paddingTop: 6 }}>{t('journey.studio.boundHint')}</p>
|
||||
)}
|
||||
{/*
|
||||
How a point is set. Only a coordinate binding has the question,
|
||||
and answering it re-sets the words on the spot rather than at
|
||||
the next open — switching a notation and seeing nothing happen
|
||||
reads as a broken control. Deliberately not `overridden`:
|
||||
choosing a format is not writing the text.
|
||||
*/}
|
||||
{el.binding?.source === 'entry.location' && el.binding.format && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.coordsMark')}>
|
||||
<Choice
|
||||
value={el.binding.format}
|
||||
options={[
|
||||
{ value: 'dms' as const, label: t('journey.studio.coordsDms') },
|
||||
{ value: 'decimal' as const, label: t('journey.studio.coordsDecimal') },
|
||||
]}
|
||||
onPick={f => {
|
||||
const entry = source.entries.find(e => e.id === el.binding!.entryId)
|
||||
const text = entry?.lat != null && entry.lng != null
|
||||
? formatBookCoords(entry.lat, entry.lng, f)
|
||||
: el.text
|
||||
set({ binding: { ...el.binding!, format: f }, text })
|
||||
}}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.typography')}>
|
||||
{/*
|
||||
Each family set in itself, because a list of names tells you
|
||||
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} />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<WeightLine font={el.font} weight={el.weight} onPick={weight => set({ weight })} t={t} />
|
||||
<Line label={t('journey.studio.align')}>
|
||||
<div className="st-row">
|
||||
{([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
|
||||
<button type="button"
|
||||
key={a}
|
||||
className={`st-chip is-icon ${el.align === a ? 'is-on' : ''}`}
|
||||
onClick={() => set({ align: a })}
|
||||
aria-label={a}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
))}
|
||||
{/* Italic sits with the alignment because both are things
|
||||
you do to a line rather than to a typeface. It had no
|
||||
control at all before, though the renderer drew it. */}
|
||||
<button type="button"
|
||||
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
|
||||
onClick={() => set({ italic: !el.italic })}
|
||||
title={italicTitle(el.font, el.weight, t)}
|
||||
aria-label={t('journey.studio.italic')}
|
||||
aria-pressed={el.italic}
|
||||
>
|
||||
<Italic size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</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.leading')} value={el.leading} min={0.7} max={3} step={0.05} onChange={v => set({ leading: v })} />
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{/*
|
||||
Letter spacing was rendered but had no control, so type the
|
||||
layout had tracked out could be edited in every way except the
|
||||
one that made it look like that.
|
||||
*/}
|
||||
<NumField
|
||||
label={t('journey.studio.tracking')}
|
||||
value={el.tracking}
|
||||
min={-0.2}
|
||||
max={1.5}
|
||||
step={0.01}
|
||||
unit="em"
|
||||
onChange={v => set({ tracking: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.kind === 'photo' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.crop')}>
|
||||
<Line label={t('journey.studio.crop')} wide>
|
||||
<Choice
|
||||
value={el.fit}
|
||||
options={(['cover', 'contain'] as const).map(f => ({
|
||||
value: f, label: t(`journey.studio.fit.${f}`),
|
||||
}))}
|
||||
onPick={fit => set({ fit })}
|
||||
/>
|
||||
</Line>
|
||||
{/*
|
||||
An empty frame has no picture to aim at, and asking for one by
|
||||
id produced a request for /api/photos/null/thumbnail and a
|
||||
broken image under a caption telling you to drag it.
|
||||
*/}
|
||||
{el.photoId != null ? (
|
||||
<FocalPad
|
||||
photoId={el.photoId}
|
||||
x={el.focalX}
|
||||
y={el.focalY}
|
||||
onDrag={(x, y) => live({ focalX: x, focalY: y })}
|
||||
onCommit={(x, y) => set({ focalX: x, focalY: y })}
|
||||
hint={t('journey.studio.focalHint')}
|
||||
/>
|
||||
) : (
|
||||
<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')}>
|
||||
<Line label={t('journey.studio.look')} wide>
|
||||
<Choice
|
||||
value={el.filter}
|
||||
options={(['none', 'bw', 'warm', 'cool', 'fade', 'contrast'] as const).map(f => ({
|
||||
value: f, label: t(`journey.studio.filter.${f}`),
|
||||
}))}
|
||||
onPick={filter => set({ filter })}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.frameStyle')} wide>
|
||||
<Choice
|
||||
value={el.frameStyle}
|
||||
options={([
|
||||
['none', 'plainFrame'],
|
||||
['polaroid', 'polaroidFrame'],
|
||||
['white', 'whiteFrame'],
|
||||
['shadow', 'shadowFrame'],
|
||||
['film', 'filmFrame'],
|
||||
['tape', 'tapeFrame'],
|
||||
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
|
||||
onPick={frameStyle => set({ frameStyle })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
{/* A corner radius and a mask are the same idea at different
|
||||
resolutions, so the radius steps aside once a shape is cut.
|
||||
It did not, and edited a property the renderer ignored. */}
|
||||
{(!el.mask || el.mask === 'rect') && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.radius')}
|
||||
value={el.radius}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={v => set({ radius: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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"
|
||||
className={`st-mini-shape ${el.mask ? '' : 'is-on'}`}
|
||||
onClick={() => set({ mask: null })}
|
||||
title={t('journey.studio.maskNone')}
|
||||
>
|
||||
<span className="st-mini-none" />
|
||||
</button>
|
||||
{FRAME_SHAPES.filter(sh => sh !== 'rect').map(sh => (
|
||||
<button type="button"
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ mask: sh })}
|
||||
aria-label={sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<EdgeSection
|
||||
edge={el.edge}
|
||||
edgeSize={el.edgeSize}
|
||||
outline={el.mask && el.mask !== 'rect' ? el.mask : null}
|
||||
onSet={patch => set(patch)}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.kind === 'shape' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.shape')}>
|
||||
{/* Every shape, not just the two the element started as: swapping
|
||||
a placed shape keeps its position, size and colour, which
|
||||
deleting and re-adding would not. */}
|
||||
<div className="st-mini-shapes">
|
||||
{SHAPE_GROUPS.flatMap(g => g.shapes).map((sh: BookShapeId) => (
|
||||
<button type="button"
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ shape: sh })}
|
||||
aria-label={sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{/* A photo had a corner radius and a shape did not, although
|
||||
both store the same field and the renderer draws both. */}
|
||||
<NumField
|
||||
label={t('journey.studio.radius')}
|
||||
value={el.radius}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={v => set({ radius: v })}
|
||||
/>
|
||||
</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.strokeStyle')} defaultOpen={false}>
|
||||
<Line label={t('journey.studio.strokeStyle')} wide>
|
||||
<Choice
|
||||
value={el.strokeStyle}
|
||||
options={([
|
||||
['solid', 'strokeSolid'],
|
||||
['dashed', 'strokeDashed'],
|
||||
['dotted', 'strokeDotted'],
|
||||
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
|
||||
onPick={strokeStyle => set({
|
||||
strokeStyle,
|
||||
// A stroke style with no stroke is invisible, so asking for
|
||||
// dashes turns the outline on rather than doing nothing.
|
||||
...(el.stroke ? {} : { stroke: el.fill ?? '#141414', strokeWidth: el.strokeWidth || 0.5 }),
|
||||
})}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.strokeWidth')}
|
||||
value={el.strokeWidth}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.1}
|
||||
unit="mm"
|
||||
onChange={v => set({ strokeWidth: v, ...(v > 0 && !el.stroke ? { stroke: '#141414' } : {}) })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.kind === 'icon' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.icon')}>
|
||||
{/*
|
||||
A search rather than the whole library: the picker in the panel
|
||||
can afford to grow as it is scrolled, but the inspector is a
|
||||
column beside a page and its job here is swapping one drawing
|
||||
for a near neighbour. Swapping keeps the position, size and
|
||||
colour that deleting and re-placing would lose.
|
||||
*/}
|
||||
<input
|
||||
className="st-input"
|
||||
value={iconQuery}
|
||||
onChange={e => setIconQuery(e.target.value)}
|
||||
placeholder={t('journey.studio.searchIcons')}
|
||||
aria-label={t('journey.studio.searchIcons')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="st-mini-shapes" style={{ marginTop: 8 }}>
|
||||
{iconChoices.map(name => {
|
||||
const Glyph = iconComponent(name)
|
||||
return (
|
||||
<button type="button"
|
||||
key={name}
|
||||
className={`st-mini-shape ${el.name === name ? 'is-on' : ''}`}
|
||||
onClick={() => set({ name })}
|
||||
aria-label={name}
|
||||
title={iconLabel(name)}
|
||||
>
|
||||
<Glyph strokeWidth={1.7} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<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
|
||||
label={t('journey.studio.lineWidth')}
|
||||
value={el.lineWidth}
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.25}
|
||||
onChange={v => set({ lineWidth: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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} />
|
||||
|
||||
{/*
|
||||
Where it sits, folded away.
|
||||
|
||||
Everything in here can be done by dragging, which is how it is
|
||||
actually done; the numbers are for the one time in ten you need the
|
||||
same margin on two pages. Opacity and rotation live here too — both
|
||||
were painted by the renderer and neither had a control anywhere.
|
||||
*/}
|
||||
{one && (
|
||||
<Section label={t('journey.studio.position')} defaultOpen={false}>
|
||||
<div className="st-grid2">
|
||||
<NumField label="X" value={el.frame.x} unit="mm" onChange={v => set({ frame: { ...el.frame, x: v } })} />
|
||||
<NumField label="Y" value={el.frame.y} unit="mm" onChange={v => set({ frame: { ...el.frame, y: v } })} />
|
||||
<NumField label={t('journey.studio.width')} value={el.frame.w} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, w: v } })} />
|
||||
<NumField label={t('journey.studio.height')} value={el.frame.h} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, h: v } })} />
|
||||
</div>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.rotation')}
|
||||
value={el.rotation}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
unit="°"
|
||||
onChange={v => set({ rotation: v })}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.opacity')}
|
||||
value={Math.round(el.opacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
unit="%"
|
||||
onChange={v => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
|
||||
/>
|
||||
</div>
|
||||
</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')}>
|
||||
<ChevronsUp size={14} />
|
||||
</button>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, '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')}>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'back')} title={t('journey.studio.toBack')}>
|
||||
<ChevronsDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/*
|
||||
The picture as the page.
|
||||
|
||||
A full-bleed photograph is the commonest layout in any photo book
|
||||
and the hardest to make by hand: the frame has to reach exactly to
|
||||
the bleed on every side, and a millimetre short leaves a white
|
||||
hairline on the trimmed page. So it is one press. It goes to the
|
||||
back as well, since a page-sized picture in front of everything is
|
||||
a page with nothing else on it.
|
||||
*/}
|
||||
{one && el.kind === 'photo' && (
|
||||
<div className="st-row" style={{ marginTop: 6 }}>
|
||||
<button type="button" className="st-chip" onClick={() => fill('page')} title={t('journey.studio.fillHint')}>
|
||||
{t('journey.studio.fillPage')}
|
||||
</button>
|
||||
{spread.role === 'inner' && (
|
||||
<button type="button" className="st-chip" onClick={() => fill('spread')} title={t('journey.studio.fillHint')}>
|
||||
{t('journey.studio.fillSpread')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
<div className="st-panel-scroll" key={selectionKey(selection)}>
|
||||
<KindInspector
|
||||
el={el}
|
||||
one={one}
|
||||
spreadIndex={spreadIndex}
|
||||
spread={spread}
|
||||
page={page}
|
||||
selection={selection}
|
||||
stats={stats}
|
||||
path={path}
|
||||
source={source}
|
||||
set={set}
|
||||
live={live}
|
||||
position={<StudioPositionSection el={el} sel={sel} set={set} t={t} />}
|
||||
t={t}
|
||||
locale={locale}
|
||||
/>
|
||||
<StudioArrangeSection
|
||||
spreadIndex={spreadIndex}
|
||||
t={t}
|
||||
onShowLayers={onOpenPanel && (() => onOpenPanel('layers'))}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the picture is anchored inside its frame.
|
||||
*
|
||||
* Two sliders were the first draft and they were the wrong instrument: a focal
|
||||
* point is one position in a plane, not two unrelated numbers, and nobody can
|
||||
* 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({
|
||||
photoId, x, y, onDrag, onCommit, hint,
|
||||
}: {
|
||||
photoId: number
|
||||
x: number
|
||||
y: number
|
||||
onDrag: (x: number, y: number) => void
|
||||
onCommit: (x: number, y: number) => void
|
||||
hint: string
|
||||
/** Copy to another page, duplicate, lock and delete: what you do to the selection, in the head. */
|
||||
function SelectionActions({ spreadIndex, el, selection, set, t }: {
|
||||
spreadIndex: number
|
||||
el: BookElement
|
||||
selection: string[]
|
||||
set: Set
|
||||
t: (k: string) => string
|
||||
}) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const last = useRef({ x, y })
|
||||
|
||||
const from = (e: React.PointerEvent) => {
|
||||
const r = box.current!.getBoundingClientRect()
|
||||
const nx = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
|
||||
const ny = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))
|
||||
last.current = { x: nx, y: ny }
|
||||
return last.current
|
||||
}
|
||||
|
||||
const duplicate = useStudioStore(s => s.duplicate)
|
||||
const copy = useStudioStore(s => s.copy)
|
||||
const removeElements = useStudioStore(s => s.removeElements)
|
||||
const lockLabel = t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')
|
||||
return (
|
||||
<div className="st-focal-wrap">
|
||||
<div
|
||||
className="st-focal"
|
||||
ref={box}
|
||||
onPointerDown={e => {
|
||||
(e.target as Element).setPointerCapture(e.pointerId)
|
||||
const p = from(e)
|
||||
onDrag(p.x, p.y)
|
||||
}}
|
||||
onPointerMove={e => {
|
||||
if (!e.currentTarget.hasPointerCapture?.(e.pointerId)) return
|
||||
const p = from(e)
|
||||
onDrag(p.x, p.y)
|
||||
}}
|
||||
onPointerUp={() => onCommit(last.current.x, last.current.y)}
|
||||
>
|
||||
<img src={photoSrc(photoId, false)} alt="" draggable={false} />
|
||||
<span className="st-focal-dot" style={{ left: `${x * 100}%`, top: `${y * 100}%` }} />
|
||||
</div>
|
||||
<p className="st-hint" style={{ padding: '6px 0 0' }}>{hint}</p>
|
||||
</div>
|
||||
<>
|
||||
<Tooltip label={t('journey.studio.copyToPage')}>
|
||||
<button type="button" className="st-act" onClick={() => copy(spreadIndex, selection)}
|
||||
aria-label={t('journey.studio.copyToPage')}>
|
||||
<ClipboardCopy size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button type="button" className="st-act" onClick={() => duplicate(spreadIndex, selection)}
|
||||
title={t('journey.studio.duplicate')} aria-label={t('journey.studio.duplicate')}>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button type="button" className={`st-act ${el.locked ? 'is-on' : ''}`} onClick={() => set({ locked: !el.locked })}
|
||||
title={lockLabel} aria-label={lockLabel} aria-pressed={el.locked}>
|
||||
{el.locked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
</button>
|
||||
<button type="button" className="st-act is-danger" onClick={() => removeElements(spreadIndex, selection)}
|
||||
title={t('journey.studio.delete')} aria-label={t('journey.studio.delete')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The groups of the selected kind, each placing the position group after what the element is. */
|
||||
function KindInspector({ el, one, spreadIndex, spread, page, selection, stats, path, source, set, live, position, t, locale }: {
|
||||
el: BookElement
|
||||
one: boolean
|
||||
spreadIndex: number
|
||||
spread: BookSpread | undefined
|
||||
page: BookPageSetup
|
||||
selection: string[]
|
||||
stats: JourneyStats | null
|
||||
path: [number, number][][]
|
||||
source: JourneySource
|
||||
set: Set
|
||||
live: Set
|
||||
position: ReactNode
|
||||
t: (k: string) => string
|
||||
locale: string
|
||||
}) {
|
||||
const theme = useStudioStore(s => s.doc?.theme)
|
||||
switch (el.kind) {
|
||||
case 'text':
|
||||
return (
|
||||
<StudioTextInspector el={el} set={set} live={live} source={source} spread={spread} theme={theme}
|
||||
position={position} t={t} />
|
||||
)
|
||||
case 'photo':
|
||||
return (
|
||||
<StudioPhotoInspector el={el} one={one} spreadIndex={spreadIndex} page={page} set={set} live={live}
|
||||
position={position} t={t} />
|
||||
)
|
||||
case 'shape':
|
||||
return (
|
||||
<StudioShapeInspector el={el} spreadIndex={spreadIndex} selection={selection} theme={theme} set={set}
|
||||
position={position} t={t} />
|
||||
)
|
||||
case 'icon':
|
||||
return <StudioIconInspector el={el} theme={theme} set={set} position={position} t={t} />
|
||||
case 'sticker':
|
||||
return <StudioStickerInspector el={el} set={set} position={position} t={t} />
|
||||
case 'line':
|
||||
return <LineInspector el={el} spreadIndex={spreadIndex} position={position} t={t} />
|
||||
case 'map':
|
||||
case 'stats':
|
||||
case 'countries':
|
||||
case 'badge':
|
||||
case 'list':
|
||||
return <TravelInspector el={el} stats={stats} path={path} set={set} t={t} locale={locale} position={position} />
|
||||
default:
|
||||
return <>{position}</>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,66 @@ describe('the layers panel', () => {
|
||||
expect(screen.getAllByText('journey.studio.layers.empty').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('is one stop in the Tab order, and the arrows walk the rows from there', () => {
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
expect(rows(container).map((r) => r.tabIndex)).toEqual([0, -1, -1, -1, -1]);
|
||||
fireEvent.keyDown(must(rows(container)[0]), { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(rows(container)[1]);
|
||||
expect(rows(container).map((r) => r.tabIndex)).toEqual([-1, 0, -1, -1, -1]);
|
||||
fireEvent.keyDown(must(rows(container)[1]), { key: 'End' });
|
||||
expect(document.activeElement).toBe(rows(container)[4]);
|
||||
fireEvent.keyDown(must(rows(container)[4]), { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(rows(container)[4]);
|
||||
fireEvent.keyDown(must(rows(container)[4]), { key: 'Home' });
|
||||
expect(document.activeElement).toBe(rows(container)[0]);
|
||||
});
|
||||
|
||||
it('selects with Enter or Space, Shift adding, and keeps the arrow off the page', () => {
|
||||
store().select(['a']);
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
// The selected row is the one Tab reaches.
|
||||
expect(must(rows(container)[4]).tabIndex).toBe(0);
|
||||
fireEvent.keyDown(must(rows(container)[0]), { key: 'Enter' });
|
||||
expect(store().selection).toEqual(['t']);
|
||||
fireEvent.keyDown(must(rows(container)[4]), { key: ' ', shiftKey: true });
|
||||
expect(store().selection).toEqual(['t', 'a']);
|
||||
// Taken by the tree, so the sheet's own keys (useStudioKeys) leave the selection where it is.
|
||||
expect(fireEvent.keyDown(must(rows(container)[4]), { key: 'ArrowUp' })).toBe(false);
|
||||
expect(fireEvent.keyDown(must(rows(container)[3]), { key: 'Delete' })).toBe(true);
|
||||
});
|
||||
|
||||
it('closes a group with Left, opens it with Right, and steps between a member and its group', () => {
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
fireEvent.keyDown(must(rows(container)[2]), { key: 'ArrowLeft' });
|
||||
expect(document.activeElement).toBe(rows(container)[1]);
|
||||
fireEvent.keyDown(must(rows(container)[1]), { key: 'ArrowLeft' });
|
||||
expect(rows(container).map((r) => r.dataset.layerKey)).toEqual(['t', 'g:g', 'a']);
|
||||
expect(must(rows(container)[1]).getAttribute('aria-expanded')).toBe('false');
|
||||
fireEvent.keyDown(must(rows(container)[1]), { key: 'ArrowRight' });
|
||||
expect(rows(container)).toHaveLength(5);
|
||||
fireEvent.keyDown(must(rows(container)[1]), { key: 'ArrowRight' });
|
||||
expect(document.activeElement).toBe(rows(container)[2]);
|
||||
});
|
||||
|
||||
it('renames with F2, and the keyboard is back on the row after Enter or Escape', () => {
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
fireEvent.keyDown(must(rows(container)[0]), { key: 'F2' });
|
||||
const input = container.querySelector('.st-layer-input') as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(input);
|
||||
fireEvent.change(input, { target: { value: 'Title' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(el('t').name).toBe('Title');
|
||||
expect(document.activeElement).toBe(rows(container)[0]);
|
||||
|
||||
fireEvent.keyDown(must(rows(container)[0]), { key: 'F2' });
|
||||
const again = container.querySelector('.st-layer-input') as HTMLInputElement;
|
||||
fireEvent.change(again, { target: { value: 'Something else' } });
|
||||
fireEvent.keyDown(again, { key: 'Escape' });
|
||||
expect(el('t').name).toBe('Title');
|
||||
expect(container.querySelector('.st-layer-input')).toBeNull();
|
||||
expect(document.activeElement).toBe(rows(container)[0]);
|
||||
});
|
||||
|
||||
it('offers no drag, lock or visibility to a book held read-only', () => {
|
||||
useStudioStore.setState({ readOnly: true });
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
@@ -175,7 +235,8 @@ describe('the align section', () => {
|
||||
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.queryByLabelText('journey.studio.layers.alignRight')).toBeNull();
|
||||
fireEvent.click(screen.getByText('journey.studio.arrange'));
|
||||
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);
|
||||
|
||||
@@ -27,7 +27,17 @@ 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 {
|
||||
dropOn,
|
||||
layerKeyAction,
|
||||
layerLabel,
|
||||
layerRows,
|
||||
rowIds,
|
||||
type LayerDrop,
|
||||
type LayerKeyAction,
|
||||
type LayerRow,
|
||||
} from './studioLayerRows';
|
||||
import { runsRtl } from './studioRoving';
|
||||
import { useStudioShortcut } from './studioShortcut';
|
||||
|
||||
/**
|
||||
@@ -41,6 +51,12 @@ import { useStudioShortcut } from './studioShortcut';
|
||||
* 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.
|
||||
*
|
||||
* The tree is one stop in the Tab order, the row last focused or else the
|
||||
* selected one, and its keys are a tree view's (studioLayerRows.ts): the arrows
|
||||
* move along it and open or close a group, Enter or Space selects a row (with
|
||||
* Shift added), F2 renames it. A hidden or locked layer, which cannot be
|
||||
* clicked on the page, is reached this way too.
|
||||
*/
|
||||
|
||||
const KIND_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -90,6 +106,8 @@ export function StudioLayersPanel({ t }: { t: T }) {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
/** The row the keyboard is on, so the tree keeps its place in the Tab order. */
|
||||
const [focusKey, setFocusKey] = useState<string | 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);
|
||||
@@ -114,14 +132,55 @@ export function StudioLayersPanel({ t }: { t: T }) {
|
||||
return next;
|
||||
});
|
||||
|
||||
const selectRow = (row: LayerRow, additive: boolean) => {
|
||||
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);
|
||||
};
|
||||
|
||||
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);
|
||||
selectRow(row, additive);
|
||||
};
|
||||
|
||||
/** The one row in the Tab order: the one last focused, else the first selected, else the front one. */
|
||||
const tabKey =
|
||||
rows.find((r) => r.key === focusKey)?.key ??
|
||||
rows.find((r) => rowIds(r).every((id) => selected.has(id)))?.key ??
|
||||
rows[0]?.key;
|
||||
|
||||
const focusRow = (row: LayerRow | undefined) => {
|
||||
if (!row) return;
|
||||
setFocusKey(row.key);
|
||||
const nodes = list.current?.querySelectorAll<HTMLElement>('[data-layer-key]') ?? [];
|
||||
Array.from(nodes)
|
||||
.find((n) => n.dataset.layerKey === row.key)
|
||||
?.focus();
|
||||
};
|
||||
|
||||
const runKey = (action: LayerKeyAction, row: LayerRow) => {
|
||||
if (action.type === 'focus') focusRow(rows[action.index]);
|
||||
else if (action.type === 'expand' || action.type === 'collapse') toggleCollapsed(action.groupId);
|
||||
else if (action.type === 'rename') {
|
||||
if (!readOnly) setRenaming(row.key);
|
||||
} else selectRow(row, action.additive);
|
||||
};
|
||||
|
||||
const onTreeKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const node = e.target as HTMLElement;
|
||||
// The caret, the toggles and the name field keep their own keys.
|
||||
const index = rows.findIndex((r) => r.key === node.dataset.layerKey);
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
const additive = e.shiftKey || e.metaKey || e.ctrlKey;
|
||||
const action = layerKeyAction(e.key, index, rows, { collapsed, rtl: runsRtl(node), additive });
|
||||
if (!action) return;
|
||||
// Taken here, so the page does not also nudge or act on the selection (useStudioKeys.ts).
|
||||
e.preventDefault();
|
||||
runKey(action, row);
|
||||
};
|
||||
|
||||
/** What a drag from this row carries: the selection when the row is in it, else what the row stands for. */
|
||||
@@ -203,6 +262,7 @@ export function StudioLayersPanel({ t }: { t: T }) {
|
||||
className={`st-layers ${drag?.active ? 'is-dragging' : ''}`}
|
||||
role="tree"
|
||||
aria-label={t('journey.studio.layers')}
|
||||
onKeyDown={onTreeKey}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={() => setDrag(null)}
|
||||
@@ -219,6 +279,8 @@ export function StudioLayersPanel({ t }: { t: T }) {
|
||||
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}
|
||||
tabStop={row.key === tabKey}
|
||||
onFocusRow={() => setFocusKey(row.key)}
|
||||
onPointerDown={(e) => onPointerDown(e, row)}
|
||||
onChoose={(additive) => choose(row, additive)}
|
||||
onToggleCollapsed={() => row.type === 'group' && toggleCollapsed(row.groupId)}
|
||||
@@ -263,6 +325,9 @@ interface RowProps {
|
||||
dragged: boolean;
|
||||
dropAt: 'above' | 'below' | null;
|
||||
readOnly: boolean;
|
||||
/** The row the Tab key reaches the tree on. */
|
||||
tabStop: boolean;
|
||||
onFocusRow: () => void;
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onChoose: (additive: boolean) => void;
|
||||
onToggleCollapsed: () => void;
|
||||
@@ -312,8 +377,11 @@ function LayerRowView(p: RowProps) {
|
||||
className={rowClasses(p, hidden)}
|
||||
data-layer-key={row.key}
|
||||
role="treeitem"
|
||||
tabIndex={p.tabStop ? 0 : -1}
|
||||
aria-level={row.type === 'element' && row.inGroup ? 2 : 1}
|
||||
aria-selected={p.selected}
|
||||
aria-expanded={row.type === 'group' ? !p.collapsed : undefined}
|
||||
onFocus={p.onFocusRow}
|
||||
onPointerDown={p.onPointerDown}
|
||||
onClick={(e) => p.onChoose(e.shiftKey || e.metaKey || e.ctrlKey)}
|
||||
>
|
||||
@@ -363,11 +431,14 @@ function RowName(p: {
|
||||
onCommit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
// Escape hands the focus back to the row, which blurs the field: that blur keeps nothing.
|
||||
const cancelled = useRef(false);
|
||||
if (!p.renaming) {
|
||||
return (
|
||||
<span
|
||||
className={`st-layer-name ${p.named ? '' : 'is-auto'}`}
|
||||
title={p.t('journey.studio.layers.rename')}
|
||||
// One line is a must in a tree of rows, so a long name is cut there and read whole here.
|
||||
title={p.label}
|
||||
onDoubleClick={own(p.onRename)}
|
||||
>
|
||||
{p.label}
|
||||
@@ -383,14 +454,28 @@ function RowName(p: {
|
||||
maxLength={60}
|
||||
aria-label={p.t('journey.studio.layers.rename')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => p.onCommit(e.target.value)}
|
||||
onFocus={() => {
|
||||
cancelled.current = false;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!cancelled.current) p.onCommit(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
// Back to the row either way, so the keyboard keeps its place in the tree.
|
||||
const row = e.currentTarget.closest<HTMLElement>('[data-layer-key]');
|
||||
if (e.key === 'Enter') {
|
||||
// Moving the focus blurs the field, which keeps the name.
|
||||
if (row) row.focus();
|
||||
else e.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Escape') return;
|
||||
// Escape here leaves the name as it was, and must not close Studio.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
cancelled.current = true;
|
||||
p.onCancel();
|
||||
row?.focus();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,17 +7,21 @@ import {
|
||||
bookLineStyled,
|
||||
} from '@trek/shared';
|
||||
import { ArrowLeftRight } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { ornamentInUse } from './inspector/inspectorModel';
|
||||
import { lineGeometry } from './lineRender';
|
||||
import { LinePaths } from './LineView';
|
||||
import { Choice, Line, NumField, Section } from './StudioControls';
|
||||
import { STROKE_STYLE_OPTIONS } from './studioOptions';
|
||||
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.
|
||||
* The inspector of a drawn line: the course it takes first (straight or
|
||||
* smooth, a wave or a zigzag, the hand-drawn wobble), then where it sits, then
|
||||
* its look (colour, weight, dashes) and what sits on each end, and folded
|
||||
* under those the ornament in its middle, open on a line that has one.
|
||||
*
|
||||
* 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
|
||||
@@ -66,10 +70,13 @@ const OrnamentGlyph = ({ ornament }: { ornament: BookLineOrnament }) => (
|
||||
export function LineInspector({
|
||||
el,
|
||||
spreadIndex,
|
||||
position,
|
||||
t,
|
||||
}: {
|
||||
el: BookLineElement;
|
||||
spreadIndex: number;
|
||||
/** The position and size group, which every kind places after what it is. */
|
||||
position: ReactNode;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const commit = useStudioStore((s) => s.commit);
|
||||
@@ -119,19 +126,29 @@ export function LineInspector({
|
||||
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 })}
|
||||
<Line label={t('journey.studio.lineCourseTitle')} wide>
|
||||
<Choice
|
||||
value={el.curve}
|
||||
ariaLabel={t('journey.studio.lineCourseTitle')}
|
||||
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}
|
||||
ariaLabel={t('journey.studio.lineBodyTitle')}
|
||||
options={(['none', 'wave', 'zigzag'] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`journey.studio.lineBody.${value}`),
|
||||
}))}
|
||||
onPick={(squiggle) => set({ squiggle })}
|
||||
/>
|
||||
</Line>
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.wobble')}
|
||||
value={Math.round(el.wobble * 100)}
|
||||
@@ -142,42 +159,34 @@ export function LineInspector({
|
||||
onChange={(v) => set({ wobble: Math.min(1, Math.max(0, v / 100)) })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.strokeStyle')} wide>
|
||||
<p className="st-hint st-hint-after">{t('journey.studio.linePointsHint')}</p>
|
||||
</Section>
|
||||
|
||||
{position}
|
||||
|
||||
<Section label={t('journey.studio.look')}>
|
||||
<Swatches value={el.stroke} onPick={(c) => set({ stroke: c })} />
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.strokeWidth')}
|
||||
value={el.strokeWidth}
|
||||
min={0.05}
|
||||
max={20}
|
||||
step={0.05}
|
||||
unit="mm"
|
||||
onChange={(v) => set({ strokeWidth: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="st-section-gap">
|
||||
<Line label={t('journey.studio.strokeStyle')}>
|
||||
<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') },
|
||||
]}
|
||||
ariaLabel={t('journey.studio.strokeStyle')}
|
||||
options={STROKE_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
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')}>
|
||||
@@ -187,7 +196,7 @@ export function LineInspector({
|
||||
<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 }}>
|
||||
<div className="st-row st-section-gap">
|
||||
<button type="button" className="st-chip" onClick={swapEnds}>
|
||||
<ArrowLeftRight size={13} />
|
||||
{t('journey.studio.swapEnds')}
|
||||
@@ -195,7 +204,7 @@ export function LineInspector({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.ornamentTitle')} defaultOpen={el.ornament !== 'none'}>
|
||||
<Section label={t('journey.studio.ornamentTitle')} defaultOpen={ornamentInUse(el)}>
|
||||
<Choice
|
||||
value={el.ornament}
|
||||
options={BOOK_LINE_ORNAMENTS.map((o) => ({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type BookElement, type BookPageSetup, bookElementId, bookNewLine } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { LinePaths } from './LineView';
|
||||
import { Shelf } from './StudioPanelHead';
|
||||
import { DIVIDER_PRESETS, LINE_PRESETS, type LinePreset as Preset, presetPoints } from './linePresets';
|
||||
import { lineGeometry } from './lineRender';
|
||||
|
||||
@@ -39,11 +41,14 @@ export function StudioLineShelf({
|
||||
single,
|
||||
onAdd,
|
||||
t,
|
||||
lead,
|
||||
}: {
|
||||
page: BookPageSetup;
|
||||
single: boolean;
|
||||
onAdd: (el: BookElement) => void;
|
||||
t: (k: string) => string;
|
||||
/** A tile before the drawn lines: the Elements panel puts its plain rule there. */
|
||||
lead?: ReactNode;
|
||||
}) {
|
||||
const add = (preset: Preset) => {
|
||||
const origin = single ? 0 : page.pageWidth;
|
||||
@@ -52,8 +57,9 @@ export function StudioLineShelf({
|
||||
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[]) => (
|
||||
const tiles = (list: readonly Preset[], first?: ReactNode) => (
|
||||
<div className="st-shape-grid">
|
||||
{first}
|
||||
{list.map((p) => {
|
||||
const label = t(`journey.studio.linePreset.${p.id}`);
|
||||
return (
|
||||
@@ -73,14 +79,8 @@ export function StudioLineShelf({
|
||||
);
|
||||
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>
|
||||
<Shelf label={t('journey.studio.drawnLines')}>{tiles(LINE_PRESETS, lead)}</Shelf>
|
||||
<Shelf label={t('journey.studio.dividers')}>{tiles(DIVIDER_PRESETS)}</Shelf>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/**
|
||||
* What the Studio says about a click it could not carry out. Today one thing:
|
||||
* the page already holds as many elements as a page can, so a sticker, a frame,
|
||||
* a paste or a duplicate was not added. Every path that refuses counts it in the
|
||||
* store, and this says it once per count rather than each path in its own words.
|
||||
*/
|
||||
export function StudioNotices() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const refused = useStudioStore((s) => s.refusedFull);
|
||||
const seen = useRef(refused);
|
||||
useEffect(() => {
|
||||
if (refused === seen.current) return;
|
||||
seen.current = refused;
|
||||
toast.warning(t('journey.studio.pageFull'));
|
||||
}, [refused, toast, t]);
|
||||
return null;
|
||||
}
|
||||
@@ -64,12 +64,19 @@ export function StudioPaintPicker({
|
||||
channel,
|
||||
t,
|
||||
noneLabel,
|
||||
kinds = KINDS,
|
||||
}: {
|
||||
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;
|
||||
/**
|
||||
* The kinds offered as tabs; all four by default. With one, the row of tabs
|
||||
* is left out: the letters of a text pick gradient or pattern beside their
|
||||
* other fills and only need that kind's controls here.
|
||||
*/
|
||||
kinds?: readonly PaintKind[];
|
||||
}) {
|
||||
const kind = kindOf(value);
|
||||
// The last paint of each kind shown here, so a kind visited again comes back as it was left.
|
||||
@@ -96,28 +103,30 @@ export function StudioPaintPicker({
|
||||
|
||||
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
|
||||
{kinds.length > 1 && (
|
||||
<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)}
|
||||
>
|
||||
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { PanelHead, Shelf } from './StudioPanelHead';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-HEAD-001 to FE-STUDIO-HEAD-003: the one head every Studio panel
|
||||
* opens on, and the shelf the panels on the left group their catalogues in.
|
||||
*/
|
||||
|
||||
describe('the panel head', () => {
|
||||
it('FE-STUDIO-HEAD-001: names the panel as a heading, with its count, a fact and its actions', () => {
|
||||
const { container } = render(
|
||||
<PanelHead label="Pages" count={7} meta="210 × 210 mm" actions={<button type="button">Act</button>} />
|
||||
);
|
||||
const heading = screen.getByRole('heading', { level: 2 });
|
||||
expect(heading.textContent).toBe('Pages7');
|
||||
expect(container.querySelector('.st-what-label')?.textContent).toBe('Pages');
|
||||
expect(container.querySelector('.st-what em')?.textContent).toBe('7');
|
||||
expect(container.querySelector('.st-head-meta')?.textContent).toBe('210 × 210 mm');
|
||||
expect(container.querySelector('.st-head-acts button')?.textContent).toBe('Act');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-002: leaves out what it is not given, and keeps a long name in a label that wraps', () => {
|
||||
const long = 'Gruppierung aufheben und Ausrichtung zurücksetzen';
|
||||
const { container } = render(<PanelHead label={long} />);
|
||||
expect(container.querySelector('.st-what em')).toBeNull();
|
||||
expect(container.querySelector('.st-head-meta')).toBeNull();
|
||||
expect(container.querySelector('.st-head-acts')).toBeNull();
|
||||
expect(container.querySelector('.st-what-label')?.textContent).toBe(long);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shelf', () => {
|
||||
it('FE-STUDIO-HEAD-003: heads its things with a label and closes with a hint, or holds them bare', () => {
|
||||
const { container, rerender } = render(
|
||||
<Shelf label="Stars" hint="Click one to add it.">
|
||||
<button type="button">Star</button>
|
||||
</Shelf>
|
||||
);
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Stars');
|
||||
expect(container.querySelector('.st-shelf > .st-shelf-hint')?.textContent).toBe('Click one to add it.');
|
||||
rerender(
|
||||
<Shelf>
|
||||
<button type="button">Star</button>
|
||||
</Shelf>
|
||||
);
|
||||
expect(screen.queryByRole('heading')).toBeNull();
|
||||
expect(container.querySelector('.st-shelf-hint')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Star' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,59 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* The bar at the top of a Studio panel: what it is, and how many.
|
||||
* The bar at the top of every Studio panel, on both sides of the page: what the
|
||||
* panel is about, how many of it there are, and the things you do to it.
|
||||
*
|
||||
* Its own file so the three panels can share it without any of them having to
|
||||
* import the others — `StudioSidebar` owns the rail, and a panel that reached
|
||||
* back into it for a header would make the sidebar impossible to split.
|
||||
* One head for all of them. The panels on the left used to open on small grey
|
||||
* capitals and the inspector on a darker title of its own, so the two sides of
|
||||
* the screen had two ideas of what a panel's top looks like, and the left ones
|
||||
* had none at all: their head was set exactly like every group label under it.
|
||||
* Now each panel opens on its name in ink, the count beside it as a quiet
|
||||
* figure, and the actions at the end. The head keeps one height whether it has
|
||||
* actions or not, so the heads of the two sides line up across the page.
|
||||
*
|
||||
* Its own file so every panel can share it without importing another panel:
|
||||
* `StudioSidebar` owns the rail, and a panel that reached back into it for a
|
||||
* header would make the sidebar impossible to split.
|
||||
*/
|
||||
export function PanelHead({ label, count }: { label: string; count?: number }) {
|
||||
export function PanelHead({ label, count, meta, actions }: {
|
||||
label: string
|
||||
count?: number
|
||||
/** A fact about the panel's subject in quiet type after the name, such as the page size. */
|
||||
meta?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="st-panel-head">
|
||||
<span>{label}</span>
|
||||
{count != null && <span style={{ fontVariantNumeric: 'tabular-nums' }}>{count}</span>}
|
||||
<h2 className="st-what">
|
||||
<span className="st-what-label">{label}</span>
|
||||
{count != null && <em>{count}</em>}
|
||||
</h2>
|
||||
{meta && <span className="st-head-meta">{meta}</span>}
|
||||
{actions && <span className="st-head-acts">{actions}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A titled group of things to add, in the panels on the left: a heading over a
|
||||
* grid or a list, and a line of help under it when it needs one.
|
||||
*
|
||||
* Never folded. A shelf is browsed, and a library whose groups had to be opened
|
||||
* one at a time would be a library read through a keyhole; the inspector's
|
||||
* groups fold because they are a reference, these are a catalogue.
|
||||
*/
|
||||
export function Shelf({ label, hint, children, className = '' }: {
|
||||
label?: string
|
||||
hint?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`st-section st-shelf ${className}`}>
|
||||
{label && <h3 className="st-section-label">{label}</h3>}
|
||||
{children}
|
||||
{hint && <p className="st-hint st-shelf-hint">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,10 +35,10 @@ export function StudioSavedLayouts({
|
||||
const scale = width / (wMm * pxPerMm)
|
||||
|
||||
return (
|
||||
<div className="st-saved-layouts">
|
||||
<div className="st-section st-saved-layouts">
|
||||
<div className="st-section-label">{t('journey.studio.myLayouts')}</div>
|
||||
{fitting.length === 0 && <p className="st-hint">{t('journey.studio.myLayoutsEmpty')}</p>}
|
||||
<div className="st-thumbs">
|
||||
<div className={`st-thumbs st-tpl-grid ${single ? 'is-pages' : 'is-spreads'}`}>
|
||||
{fitting.map(layout => (
|
||||
<div className="st-thumb-row" key={layout.id}>
|
||||
<button type="button" className="st-thumb" onClick={() => applyLayout(active, layout.id)}>
|
||||
@@ -79,7 +79,6 @@ export function StudioSavedLayouts({
|
||||
{t('journey.studio.saveLayout')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="st-section-label st-builtin-label">{t('journey.studio.builtInLayouts')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The search field of a Studio panel: the photos and entries of the journey,
|
||||
* the icon library, the stickers. One field, so the three look and behave
|
||||
* alike, the clear button included. It sits above the list it searches rather
|
||||
* than inside it, so it stays in reach while the list scrolls.
|
||||
*
|
||||
* A library with no tab of its own to count on (the icons, the stickers) gives
|
||||
* the field its count, and the field shows it while something is typed: how
|
||||
* many match is the first thing a search answers.
|
||||
*/
|
||||
export function StudioSearch({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
clearLabel,
|
||||
count,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** The placeholder and the field's accessible name. */
|
||||
label: string;
|
||||
clearLabel: string;
|
||||
/** How many the words match, shown beside them while there are any. */
|
||||
count?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="st-search">
|
||||
<Search size={14} aria-hidden />
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={label}
|
||||
aria-label={label}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{value.trim() !== '' && count != null && <output className="st-search-count">{count}</output>}
|
||||
{value && (
|
||||
<button type="button" onClick={() => onChange('')} aria-label={clearLabel}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
type BookShapeElement,
|
||||
type BookTheme,
|
||||
} from '@trek/shared';
|
||||
import { shapeOutlineInUse } from './inspector/inspectorModel';
|
||||
import { usePaintChannel } from './paint/usePaintChannel';
|
||||
import { Line, Section } from './StudioControls';
|
||||
import { Choice, Line, NumField, Section } from './StudioControls';
|
||||
import { STROKE_STYLE_OPTIONS } from './studioOptions';
|
||||
import { StudioPaintPicker } from './StudioPaintPicker';
|
||||
import type { StudioT } from './StudioSidebar';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
@@ -16,19 +18,20 @@ 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';
|
||||
|
||||
type SetShape = (patch: Partial<BookShapeElement>) => void;
|
||||
|
||||
/**
|
||||
* A shape's colours: its fill as a paint, and its outline.
|
||||
* A shape's fill, as a paint: none, one colour, a gradient or a pattern.
|
||||
*
|
||||
* 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({
|
||||
export function StudioShapeFill({
|
||||
el,
|
||||
spreadIndex,
|
||||
selection,
|
||||
onStroke,
|
||||
theme,
|
||||
set,
|
||||
t,
|
||||
@@ -36,11 +39,10 @@ export function StudioShapePaint({
|
||||
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. */
|
||||
/** The book's theme, for the theme colours under the paint; none without one. */
|
||||
theme?: BookTheme;
|
||||
/** The inspector's write, for linking a colour to the theme. */
|
||||
set?: (patch: Partial<BookShapeElement>) => void;
|
||||
/** The inspector's write, for linking the fill to the theme. */
|
||||
set: SetShape;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const channel = usePaintChannel((paint) => (doc) => ({
|
||||
@@ -63,31 +65,75 @@ export function StudioShapePaint({
|
||||
}));
|
||||
|
||||
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 label={t('journey.studio.fill')}>
|
||||
<StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} />
|
||||
<StudioThemeLink
|
||||
theme={theme}
|
||||
token={el.token}
|
||||
t={t}
|
||||
onLink={(token, fill) => set({ fill, token, paint: undefined })}
|
||||
onUnlink={() => set({ token: undefined })}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A shape's outline: its colour, the stroke it is drawn in and how wide.
|
||||
*
|
||||
* Folded until the shape has one, since most shapes are a fill and nothing
|
||||
* else. Every way of asking for an outline brings one: a colour, a width or a
|
||||
* dash on a shape that had none draws it rather than doing nothing.
|
||||
*/
|
||||
export function StudioShapeOutline({
|
||||
el,
|
||||
theme,
|
||||
set,
|
||||
t,
|
||||
}: {
|
||||
el: BookShapeElement;
|
||||
theme?: BookTheme;
|
||||
set: SetShape;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const width = el.strokeWidth || 0.5;
|
||||
return (
|
||||
<Section label={t('journey.studio.stroke')} defaultOpen={shapeOutlineInUse(el)}>
|
||||
<Swatches value={el.stroke ?? OUTLINE} onPick={(stroke) => set({ stroke, strokeWidth: width })} />
|
||||
<StudioThemeLink
|
||||
theme={theme}
|
||||
token={el.strokeToken}
|
||||
t={t}
|
||||
onLink={(strokeToken, stroke) => set({ stroke, strokeToken, strokeWidth: width })}
|
||||
onUnlink={() => set({ strokeToken: undefined })}
|
||||
/>
|
||||
<div className="st-section-gap">
|
||||
<Line label={t('journey.studio.strokeStyle')}>
|
||||
<Choice
|
||||
value={el.strokeStyle}
|
||||
ariaLabel={t('journey.studio.strokeStyle')}
|
||||
options={STROKE_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
onPick={(strokeStyle) =>
|
||||
set({
|
||||
strokeStyle,
|
||||
// A stroke style with no stroke is invisible, so asking for dashes turns the outline on.
|
||||
...(el.stroke ? {} : { stroke: el.fill ?? OUTLINE, strokeWidth: width }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.strokeWidth')}
|
||||
value={el.strokeWidth}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.1}
|
||||
unit="mm"
|
||||
onChange={(v) => set({ strokeWidth: v, ...(v > 0 && !el.stroke ? { stroke: OUTLINE } : {}) })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, Minus, Plus,
|
||||
Redo2, Sparkles, Undo2,
|
||||
} from 'lucide-react'
|
||||
import { BookOpen, Download, Maximize2, Minus, Plus } from 'lucide-react'
|
||||
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { foliosOf } from './bookSheets'
|
||||
import { StudioSidebar } from './StudioSidebar'
|
||||
import type { RailSection } from './sidebar/railSections'
|
||||
import { ProviderPicker } from '../Journey/JourneyDetailPageProviderPicker'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { StudioCanvas } from './StudioCanvas'
|
||||
import { StudioInspector } from './StudioInspector'
|
||||
import { StudioPaletteScope } from './paint/StudioPalette'
|
||||
import { StudioWordmark } from './StudioWordmark'
|
||||
import { SaveIndicator } from './SaveIndicator'
|
||||
import { downloadSpread } from './spreadFile'
|
||||
import { StudioExport } from './StudioExport'
|
||||
import { PeerBadges } from './PeerBadges'
|
||||
import { TrimField } from './TrimField'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { StudioAssistantColumn, StudioAssistantToggle, StudioPageStage } from './StudioAssistantPanel'
|
||||
import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
|
||||
import { StudioAssistantColumn, StudioPageStage } from './StudioAssistantPanel'
|
||||
import { useStudioAssistant } from './useStudioAssistant'
|
||||
import { StudioHeader } from './StudioHeader'
|
||||
import { NewerBookOverlay } from './SaveIndicator'
|
||||
import { StudioNotices } from './StudioNotices'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
import './bookFontFaces'
|
||||
import { useWarmBookFaces } from './bookFontWarm'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
|
||||
/**
|
||||
* The Studio shell: top bar, page rail, workbench, inspector.
|
||||
@@ -46,6 +40,8 @@ export default function StudioShell() {
|
||||
const navigate = useNavigate()
|
||||
const [bookView, setBookView] = useState(true)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
// The rail's open section lives here, so the inspector can open the Theme or the Layers panel beside it.
|
||||
const [railSection, setRailSection] = useState<RailSection>('pages')
|
||||
const assistant = useStudioAssistant({ journeyId: s.journeyId, flush: s.flushBook })
|
||||
// The book's faces into the offline cache as it opens (bookFontWarm.ts).
|
||||
useWarmBookFaces()
|
||||
@@ -90,13 +86,10 @@ export default function StudioShell() {
|
||||
aria-modal="true"
|
||||
aria-label={s.t('journey.studio.title')}
|
||||
>
|
||||
<StudioBar
|
||||
s={s}
|
||||
bookView={bookView}
|
||||
setBookView={setBookView}
|
||||
onExport={() => setExporting(true)}
|
||||
assistant={assistant}
|
||||
/>
|
||||
<StudioHeader s={s} onExport={() => setExporting(true)} assistant={assistant} />
|
||||
<StudioNotices />
|
||||
{/* A newer Studio saved this book: nothing done here would be kept, so the whole Studio waits for a reload. */}
|
||||
{s.saveState.status === 'newer' && <NewerBookOverlay t={s.t} />}
|
||||
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
|
||||
{s.providerPick && s.journey && (
|
||||
<ProviderPicker
|
||||
@@ -126,8 +119,10 @@ export default function StudioShell() {
|
||||
onToggleStop={s.setStopExcluded}
|
||||
providers={s.photoProviders}
|
||||
onBrowseProvider={s.browseProvider}
|
||||
section={railSection}
|
||||
onSection={setRailSection}
|
||||
/>
|
||||
<Workbench s={s} bookView={bookView} />
|
||||
<Workbench s={s} bookView={bookView} setBookView={setBookView} />
|
||||
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
|
||||
<StudioInspector
|
||||
spreadIndex={s.activeSpread}
|
||||
@@ -138,6 +133,7 @@ export default function StudioShell() {
|
||||
setPageNumbers={s.setPageNumbers}
|
||||
t={s.t}
|
||||
locale={s.locale}
|
||||
onOpenPanel={setRailSection}
|
||||
/>
|
||||
</StudioPaletteScope>
|
||||
</div>
|
||||
@@ -158,339 +154,10 @@ export default function StudioShell() {
|
||||
)
|
||||
}
|
||||
|
||||
type Studio = ReturnType<typeof useJourneyStudio>
|
||||
export type Studio = ReturnType<typeof useJourneyStudio>
|
||||
|
||||
function StudioBar({
|
||||
s, bookView, setBookView, onExport, assistant,
|
||||
}: {
|
||||
s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void
|
||||
assistant: StudioAssistant
|
||||
}) {
|
||||
return (
|
||||
<div className="st-bar">
|
||||
<button type="button" onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
|
||||
<ArrowLeft size={16} />
|
||||
{s.coverUrl
|
||||
? <img src={s.coverUrl} alt="" className="st-cover" />
|
||||
: <span className="st-cover" />}
|
||||
<span className="st-back-label">{s.journey?.title || s.t('journey.title')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
{/*
|
||||
The lockup, not a name field. The book is the journey's, and it was
|
||||
already titled after it — a second place to type the same name is a
|
||||
field whose only job is to be left alone. What the bar is short of is
|
||||
somewhere to say what this thing *is*.
|
||||
*/}
|
||||
<div className="st-brand">
|
||||
<StudioWordmark style={{ height: 26 }} />
|
||||
<span className="st-beta">{s.t('journey.studio.beta')}</span>
|
||||
</div>
|
||||
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={current => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => { void s.saveNow() }}
|
||||
/>
|
||||
|
||||
<div className="st-bar-group">
|
||||
<button type="button"
|
||||
className={`st-tool ${bookView ? 'is-on' : ''}`}
|
||||
onClick={() => setBookView(!bookView)}
|
||||
title={s.t('journey.studio.bookView')}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.bookView')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<FormatPicker s={s} />
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canUndo}
|
||||
onClick={s.undo}
|
||||
title={s.t('journey.studio.undo')}
|
||||
aria-label={s.t('journey.studio.undo')}
|
||||
>
|
||||
<Undo2 size={15} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canRedo}
|
||||
onClick={s.redo}
|
||||
title={s.t('journey.studio.redo')}
|
||||
aria-label={s.t('journey.studio.redo')}
|
||||
>
|
||||
<Redo2 size={15} />
|
||||
</button>
|
||||
<PasteButton s={s} />
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<AutoLayoutButton s={s} />
|
||||
{/* The book's assistant, as a column beside the page; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
<button type="button"
|
||||
className="st-tool is-primary"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out again — this spread, or the whole book.
|
||||
*
|
||||
* A split button rather than two: they are the same action at two scopes, and
|
||||
* the spread is the one you want nine times out of ten. Rebuilding the whole
|
||||
* book is behind the chevron, where it cannot be hit by accident, and both are
|
||||
* ordinary undo steps.
|
||||
*/
|
||||
/**
|
||||
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
|
||||
* because it is the same kind of thing, and there so the keyboard shortcut is
|
||||
* not the only way to find it.
|
||||
*/
|
||||
function PasteButton({ s }: { s: Studio }) {
|
||||
const clipboard = useStudioStore(st => st.clipboard)
|
||||
const paste = useStudioStore(st => st.paste)
|
||||
const label = s.t('journey.studio.paste')
|
||||
return (
|
||||
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!clipboard || !s.canEdit}
|
||||
onClick={() => paste(s.activeSpread)}
|
||||
aria-label={label}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={s.t('journey.studio.autoLayout')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<button type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!s.canRelayoutSpread}
|
||||
onClick={() => { s.relayoutCurrentSpread(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
|
||||
<span className="st-menu-dim">
|
||||
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => { s.relayoutBook(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
|
||||
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The format picker.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
* runs off the bottom of the screen. This is a small popover instead — themed,
|
||||
* anchored under the bar, and it shows each format's proportions rather than
|
||||
* only naming them.
|
||||
*/
|
||||
function FormatPicker({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Swallow it, or the shell's Escape handler would close Studio itself.
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210']
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={s.t('journey.studio.format')}
|
||||
>
|
||||
<span className="st-tool-label">
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map(id => {
|
||||
const p = PAGE_PRESETS[id]
|
||||
const on = id === s.preset
|
||||
return (
|
||||
<button type="button"
|
||||
key={id}
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={() => { s.setPreset(id); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-tile">
|
||||
<span
|
||||
className="st-menu-shape"
|
||||
style={
|
||||
p.pageWidthMm >= p.pageHeightMm
|
||||
? { width: 24, height: Math.round(24 * p.pageHeightMm / p.pageWidthMm) }
|
||||
: { height: 24, width: Math.round(24 * p.pageWidthMm / p.pageHeightMm) }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t(p.labelKey)}</span>
|
||||
<span className="st-menu-dim">{p.pageWidthMm} × {p.pageHeightMm} mm</span>
|
||||
</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/*
|
||||
The two fields live under the list rather than behind a "custom"
|
||||
entry that opens a second popover: picking the custom row and then
|
||||
typing into the fields it reveals is two steps for one decision, and
|
||||
typing into them *is* picking custom — the hook switches the preset
|
||||
for you.
|
||||
*/}
|
||||
<div className="st-menu-sep" />
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.width')}
|
||||
value={s.page.pageWidth}
|
||||
onCommit={v => s.setPageSize('w', v)}
|
||||
/>
|
||||
<span className="st-menu-times">×</span>
|
||||
<TrimField
|
||||
label={s.t('journey.studio.height')}
|
||||
value={s.page.pageHeight}
|
||||
onCommit={v => s.setPageSize('h', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
What the press takes off, and what it must not print into.
|
||||
|
||||
Under the trim size because they answer the same question — how big
|
||||
is this book, really — and because a vendor states all three in one
|
||||
breath. They were fixed at 3 and 5, which is what most photo-book
|
||||
printers ask for and is useless to the one that asks for 5 and 10.
|
||||
*/}
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.bleed')}
|
||||
value={s.page.bleed}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('bleed', v)}
|
||||
/>
|
||||
<span className="st-menu-times" />
|
||||
<TrimField
|
||||
label={s.t('journey.studio.safeArea')}
|
||||
value={s.page.safe}
|
||||
min={0}
|
||||
max={40}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('safe', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
function Workbench({ s, bookView, setBookView }: { s: Studio; bookView: boolean; setBookView: (on: boolean) => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="st-work-wrap">
|
||||
<div className="st-work" ref={s.workRef}>
|
||||
@@ -532,6 +199,16 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
<button type="button" onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
{/* How the page is looked at, so with the zoom rather than in the head band. */}
|
||||
<button type="button"
|
||||
onClick={() => setBookView(!bookView)}
|
||||
aria-pressed={bookView}
|
||||
aria-label={t('journey.studio.bookView')}
|
||||
title={t('journey.studio.bookView')}
|
||||
className={bookView ? 'is-on' : undefined}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
{/*
|
||||
The page, as a file.
|
||||
|
||||
|
||||
@@ -1,50 +1,39 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Camera, ChevronDown, ImagePlus, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, Layers, LayoutTemplate,
|
||||
Palette, Plus, Search, Shapes, Sticker, Trash2, Upload, X,
|
||||
} from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { useElementSize } from '../../hooks/useElementSize'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { SpreadThumb } from './SpreadThumb'
|
||||
import { folioLabel, foliosOf } from './bookSheets'
|
||||
import { photoSrc } from './bookRender'
|
||||
import {
|
||||
BOOK_COVER_TEMPLATES as COVER_TEMPLATES, BOOK_TEMPLATES as TEMPLATES, applyBookTemplate as applyTemplate,
|
||||
bookElementId as uid,
|
||||
} from '@trek/shared'
|
||||
import {
|
||||
bookCoordValue as coordValue, formatBookCoords, formatBookDate, type BookCoordFormat as CoordFormat,
|
||||
} from '@trek/shared'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { PanelHead as Head } from './StudioPanelHead'
|
||||
import { useState } from 'react'
|
||||
import type { BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { StudioElementsPanel } from './StudioElementsPanel'
|
||||
import { StudioStickersPanel } from './StudioStickersPanel'
|
||||
import { StudioLayersPanel } from './StudioLayersPanel'
|
||||
import { StudioTravelPanel } from './StudioTravelPanel'
|
||||
import { StudioThemePanel } from './theme/StudioThemePanel'
|
||||
import { StudioSavedLayouts } from './StudioSavedLayouts'
|
||||
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
|
||||
import { StudioContentFilter } from './StudioContentFilter'
|
||||
import { emptyKeyFor, matchingEntries, matchingPhotos, photosFor, type PhotoFilter } from './photoFilter'
|
||||
import { StudioTemplatesPanel } from './templates/StudioTemplatesPanel'
|
||||
import { StudioRail } from './sidebar/StudioRail'
|
||||
import { StudioPagesPanel } from './sidebar/StudioPagesPanel'
|
||||
import { StudioContentPanel } from './sidebar/StudioContentPanel'
|
||||
import { RAIL_IDS, type RailSection } from './sidebar/railSections'
|
||||
import type { StudioUploader } from './studioUpload'
|
||||
|
||||
/**
|
||||
* The left side of Studio: a narrow rail of sections and one wide panel showing
|
||||
* whichever is open.
|
||||
*
|
||||
* The shape is Canva's and it is the right one here for a plain reason — a book
|
||||
* has four completely different kinds of thing you reach for (which page, what
|
||||
* from the journey, what to draw, what arrangement), and putting them behind
|
||||
* tabs in a single 200px column would make each of them cramped. An icon rail
|
||||
* costs 52px and gives the panel the room a photo browser actually needs.
|
||||
* The shape is Canva's and it is the right one here for a plain reason: a book
|
||||
* has a handful of completely different kinds of thing you reach for (which
|
||||
* page, what from the journey, what arrangement, what to draw), and putting
|
||||
* them behind tabs in a single 200px column would make each of them cramped.
|
||||
* An icon rail costs 64px and gives the panel the room a photo browser needs.
|
||||
*
|
||||
* Which section is open is the shell's when it says so (`section` and
|
||||
* `onSection`), so the inspector on the other side can open the Theme or the
|
||||
* Layers panel; on its own the sidebar keeps it itself and opens on Pages.
|
||||
*/
|
||||
|
||||
type Section = 'pages' | 'content' | 'elements' | 'stickers' | 'travel' | 'templates' | 'layers' | 'theme'
|
||||
|
||||
export interface JourneySource {
|
||||
/**
|
||||
* The journey's title and subtitle, which a cover is set in when the book has
|
||||
* no title of its own. The Studio always gives them; a source built only for
|
||||
* the content browser may leave them out.
|
||||
*/
|
||||
title?: string
|
||||
subtitle?: string | null
|
||||
entries: {
|
||||
id: number
|
||||
title: string | null
|
||||
@@ -70,29 +59,9 @@ export interface JourneySource {
|
||||
/** The translator, with the parameters some of the strings here take. */
|
||||
export type StudioT = (k: string, params?: Record<string, string | number>) => string
|
||||
|
||||
/**
|
||||
* How wide a page preview may be drawn.
|
||||
*
|
||||
* Page thumbnails and layout cards are drawn at a `scale()` rather than laid
|
||||
* out by CSS — the sheet inside them is sized in millimetres — so they need an
|
||||
* actual pixel width, and it is **measured** rather than derived.
|
||||
*
|
||||
* Deriving it was tried twice and was wrong twice. The panel width, the scroll
|
||||
* padding and the button's own border and padding are all knowable from
|
||||
* studio.css; the scrollbar is not. It appears only once the list is long
|
||||
* enough, takes a width that depends on the platform, and the arithmetic that
|
||||
* ignored it produced a card overhanging its own selection outline. Measuring
|
||||
* the row the cards sit in gets all four contributions at once and cannot drift
|
||||
* when any of them changes.
|
||||
*/
|
||||
const MIN_PREVIEW_W = 120
|
||||
|
||||
/** The button's border and padding, which the row's width still includes. */
|
||||
const THUMB_CHROME = (2 + 3) * 2
|
||||
|
||||
export function StudioSidebar({
|
||||
page, pxPerMm, bookView, source, stats, path, t, locale,
|
||||
canEdit, onUpload, onToggleStop, providers = [], onBrowseProvider,
|
||||
canEdit, onUpload, onToggleStop, providers = [], onBrowseProvider, section, onSection,
|
||||
}: {
|
||||
page: BookPageSetup
|
||||
pxPerMm: number
|
||||
@@ -114,46 +83,38 @@ export function StudioSidebar({
|
||||
providers?: { id: string; name: string }[]
|
||||
/** Opens the provider picker; answers the photo ids that landed, none when closed. */
|
||||
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>
|
||||
/** The open section, when the shell holds it. */
|
||||
section?: RailSection
|
||||
onSection?: (section: RailSection) => void
|
||||
}) {
|
||||
const [section, setSection] = useState<Section>('pages')
|
||||
|
||||
const SECTIONS: { id: Section; icon: typeof Files; labelKey: string }[] = [
|
||||
{ id: 'pages', icon: Files, labelKey: 'journey.studio.pages' },
|
||||
{ id: 'content', icon: ImageIcon, labelKey: 'journey.studio.content' },
|
||||
{ id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' },
|
||||
{ id: 'stickers', icon: Sticker, labelKey: 'journey.studio.stickers' },
|
||||
{ id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' },
|
||||
{ id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' },
|
||||
{ id: 'layers', icon: Layers, labelKey: 'journey.studio.layers' },
|
||||
{ id: 'theme', icon: Palette, labelKey: 'studio.theme.panel' },
|
||||
]
|
||||
const [own, setOwn] = useState<RailSection>('pages')
|
||||
const open = section ?? own
|
||||
const setOpen = (next: RailSection) => {
|
||||
setOwn(next)
|
||||
onSection?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="st-rail" aria-label={t('journey.studio.sections')}>
|
||||
{SECTIONS.map(sec => (
|
||||
<button type="button"
|
||||
key={sec.id}
|
||||
className={`st-rail-btn ${section === sec.id ? 'is-on' : ''}`}
|
||||
onClick={() => setSection(sec.id)}
|
||||
title={t(sec.labelKey)}
|
||||
aria-label={t(sec.labelKey)}
|
||||
>
|
||||
<sec.icon size={18} strokeWidth={1.7} />
|
||||
<span>{t(sec.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<StudioRail section={open} onSection={setOpen} t={t} />
|
||||
|
||||
<aside className="st-panel st-side">
|
||||
{section === 'pages' && <PagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
|
||||
{section === 'content' && (
|
||||
<ContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload}
|
||||
<aside
|
||||
className="st-panel st-side"
|
||||
id={RAIL_IDS.panel}
|
||||
role="tabpanel"
|
||||
aria-labelledby={RAIL_IDS.tab(open)}
|
||||
>
|
||||
{open === 'pages' && <StudioPagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
|
||||
{open === 'content' && (
|
||||
<StudioContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload}
|
||||
providers={providers} onBrowseProvider={onBrowseProvider} />
|
||||
)}
|
||||
{section === 'elements' && <StudioElementsPanel page={page} t={t} source={source} />}
|
||||
{section === 'stickers' && <StudioStickersPanel page={page} t={t} />}
|
||||
{section === 'travel' && (
|
||||
{open === 'templates' && (
|
||||
<StudioTemplatesPanel page={page} pxPerMm={pxPerMm} t={t} locale={locale} source={source} stats={stats}
|
||||
path={path} onOpenContent={() => setOpen('content')} />
|
||||
)}
|
||||
{open === 'elements' && <StudioElementsPanel page={page} t={t} source={source} />}
|
||||
{open === 'travel' && (
|
||||
<StudioTravelPanel
|
||||
page={page}
|
||||
stats={stats}
|
||||
@@ -164,715 +125,9 @@ export function StudioSidebar({
|
||||
onToggleStop={onToggleStop}
|
||||
/>
|
||||
)}
|
||||
{section === 'layers' && <StudioLayersPanel t={t} />}
|
||||
{section === 'theme' && <StudioThemePanel t={t} />}
|
||||
{section === 'templates' && <TemplatesPanel page={page} pxPerMm={pxPerMm} t={t} onOpenContent={() => setSection('content')} />}
|
||||
{open === 'theme' && <StudioThemePanel t={t} />}
|
||||
{open === 'layers' && <StudioLayersPanel t={t} />}
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PagesPanel({
|
||||
page, pxPerMm, bookView, t,
|
||||
}: { page: BookPageSetup; pxPerMm: number; bookView: boolean; t: (k: string) => string }) {
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
// The page a chat card points at, outlined here (journey-studio.md 4.2).
|
||||
const hinted = useStudioStore(s => s.hintSpread)
|
||||
const setActive = useStudioStore(s => s.setActiveSpread)
|
||||
const addSpread = useStudioStore(s => s.addSpread)
|
||||
const insertSpread = useStudioStore(s => s.insertSpread)
|
||||
const duplicateSpread = useStudioStore(s => s.duplicateSpread)
|
||||
const removeSpread = useStudioStore(s => s.removeSpread)
|
||||
const moveSpread = useStudioStore(s => s.moveSpread)
|
||||
const canEditSpread = useStudioStore(s => s.canEditSpread)
|
||||
const spreads = doc?.spreads ?? []
|
||||
const list = useElementSize<HTMLDivElement>()
|
||||
const THUMB_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME)
|
||||
|
||||
/** Where a new spread would land: after the last inner one. */
|
||||
const lastInner = spreads.reduce((last, sp, i) => (sp.role === 'inner' ? i : last), -1)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [importError, setImportError] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head label={t('journey.studio.pages')} count={spreads.length} />
|
||||
<div className="st-panel-scroll">
|
||||
<div className="st-thumbs" ref={list.ref}>
|
||||
{spreads.map((sp, i) => {
|
||||
const editable = canEditSpread(i)
|
||||
return (
|
||||
<div className="st-thumb-row" key={sp.id}>
|
||||
<button type="button"
|
||||
className={`st-thumb ${i === active ? 'is-active' : ''} ${sp.id === hinted ? 'is-hinted' : ''}`}
|
||||
onClick={() => setActive(i)}
|
||||
>
|
||||
<SpreadThumb
|
||||
spread={sp}
|
||||
page={page}
|
||||
width={THUMB_W}
|
||||
pxPerMm={pxPerMm}
|
||||
folios={foliosOf(spreads, i, page.pageNumbers.startAt)}
|
||||
fold={bookView}
|
||||
className="st-thumb-sheet"
|
||||
/>
|
||||
<span className="st-thumb-label">
|
||||
{sp.role === 'cover' ? t('journey.studio.cover')
|
||||
: sp.role === 'back' ? t('journey.studio.backCover')
|
||||
: folioLabel(foliosOf(spreads, i, page.pageNumbers.startAt))}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/*
|
||||
The actions sit on the thumbnail rather than in a menu bar of
|
||||
their own: what you are acting on is the page you are pointing
|
||||
at, and a toolbar somewhere else would need you to select
|
||||
first and act second. The cover and the back cover have no
|
||||
actions — a book has exactly one of each, and moving or
|
||||
deleting them is not a thing anyone means to do.
|
||||
*/}
|
||||
{editable && (
|
||||
<div className="st-thumb-actions">
|
||||
<button type="button"
|
||||
onClick={() => moveSpread(i, -1)}
|
||||
disabled={!canEditSpread(i - 1)}
|
||||
title={t('journey.studio.movePageUp')}
|
||||
aria-label={t('journey.studio.movePageUp')}
|
||||
>
|
||||
<ChevronUp size={13} />
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => moveSpread(i, 1)}
|
||||
disabled={!canEditSpread(i + 1)}
|
||||
title={t('journey.studio.movePageDown')}
|
||||
aria-label={t('journey.studio.movePageDown')}
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => duplicateSpread(i)}
|
||||
title={t('journey.studio.duplicatePage')}
|
||||
aria-label={t('journey.studio.duplicatePage')}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className="is-danger"
|
||||
onClick={() => removeSpread(i)}
|
||||
title={t('journey.studio.deletePage')}
|
||||
aria-label={t('journey.studio.deletePage')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Insert between two spreads, where the new one will go — a
|
||||
single "add" at the end cannot express "another page here". */}
|
||||
{editable && (
|
||||
<button type="button"
|
||||
className="st-thumb-insert"
|
||||
onClick={() => addSpread(i)}
|
||||
title={t('journey.studio.addPageAfter')}
|
||||
aria-label={t('journey.studio.addPageAfter')}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Two ways to get a new page: an empty one, or somebody else's design.
|
||||
Side by side because they answer the same question — the import is
|
||||
not a setting buried in a menu, it is the other half of "add".
|
||||
*/}
|
||||
<div className="st-add-row">
|
||||
<button type="button" className="st-add-page" onClick={() => addSpread(lastInner)}
|
||||
title={t('journey.studio.addPage')} aria-label={t('journey.studio.addPage')}>
|
||||
<Plus size={14} />
|
||||
{t('journey.studio.addPageShort')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-add-page is-import"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
title={t('journey.studio.importSpreadHint')}
|
||||
>
|
||||
<FileDown size={14} />
|
||||
{t('journey.studio.importSpread')}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files?.[0]
|
||||
// Cleared straight away so choosing the same file twice in a row
|
||||
// still fires a change event the second time.
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setImportError(null)
|
||||
// Checked before reading rather than after: the point of a limit on
|
||||
// a file someone else wrote is not to parse it in the first place.
|
||||
if (file.size > MAX_SPREAD_FILE_BYTES) { setImportError(t('journey.studio.importSpreadFailed')); return }
|
||||
try {
|
||||
const spread = importSpread(JSON.parse(await file.text()), page)
|
||||
if (!spread) { setImportError(t('journey.studio.importSpreadFailed')); return }
|
||||
insertSpread(lastInner, spread)
|
||||
} catch {
|
||||
setImportError(t('journey.studio.importSpreadFailed'))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{importError && <p className="st-add-error">{importError}</p>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The journey's own material.
|
||||
*
|
||||
* This is what separates a book maker from a drawing program: the pictures and
|
||||
* the words are already written, and the job is putting them on pages. Clicking
|
||||
* an item drops it on the current spread, centred, at a sensible size.
|
||||
*
|
||||
* Pictures can also come in here. A book is where you notice the one that
|
||||
* never made it into the journal, and going back to the journey to add it, then
|
||||
* finding the page again, was the one round trip Studio still asked for. What
|
||||
* comes in goes through the journey store like any other upload, so the journal
|
||||
* sees it the moment the panel does.
|
||||
*/
|
||||
function ContentPanel({
|
||||
source, page, t, locale, canEdit, onUpload, providers = [], onBrowseProvider,
|
||||
}: {
|
||||
source: JourneySource
|
||||
page: BookPageSetup
|
||||
t: StudioT
|
||||
locale: string
|
||||
canEdit: boolean
|
||||
onUpload: StudioUploader
|
||||
providers?: { id: string; name: string }[]
|
||||
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>
|
||||
}) {
|
||||
const [tab, setTab] = useState<'photos' | 'text'>('photos')
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<PhotoFilter>({ kind: 'all' })
|
||||
const toast = useToast()
|
||||
|
||||
const entries = matchingEntries(source, query)
|
||||
const admitted = photosFor(source, filter)
|
||||
const photos = matchingPhotos(admitted, source, query)
|
||||
const emptyKey = emptyKeyFor(source, filter, admitted)
|
||||
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [upload, setUpload] = useState<{ done: number; total: number } | null>(null)
|
||||
/* A ref as well as the state: two drops in one second would both read the
|
||||
state before either had set it. */
|
||||
const sending = useRef(false)
|
||||
const [dropOver, setDropOver] = useState(false)
|
||||
|
||||
/**
|
||||
* Into the entry the browser is filtered to, otherwise into the gallery. The
|
||||
* filter is the one thing on screen that names an entry, so it is also the
|
||||
* one thing that can say where a picture belongs; the line under it says so
|
||||
* before the press. Afterwards the new pictures get a filter of their own,
|
||||
* because the gallery sorts them to its end, and twelve pictures that arrive
|
||||
* out of sight read as twelve that did not arrive.
|
||||
*/
|
||||
const send = async (files: File[]) => {
|
||||
if (sending.current || !files.length) return
|
||||
sending.current = true
|
||||
setUpload({ done: 0, total: files.length })
|
||||
try {
|
||||
const entryId = filter.kind === 'entry' ? filter.id : null
|
||||
const sent = await onUpload(files, entryId, p => setUpload({ done: p.done, total: p.total }))
|
||||
if (sent.photoIds.length) {
|
||||
setFilter({ kind: 'recent', photoIds: sent.photoIds })
|
||||
setQuery('')
|
||||
toast.success(t('journey.photosUploaded', { count: sent.photoIds.length }))
|
||||
}
|
||||
} finally {
|
||||
sending.current = false
|
||||
setUpload(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* From a photo provider (#2271): into the same place an upload goes, and the
|
||||
* new pictures shown the same way, under their own filter.
|
||||
*/
|
||||
const pickFromProvider = async (provider: string) => {
|
||||
if (!onBrowseProvider) return
|
||||
const photoIds = await onBrowseProvider(provider, filter.kind === 'entry' ? filter.id : null)
|
||||
if (photoIds.length) {
|
||||
setFilter({ kind: 'recent', photoIds })
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The whole scrolling area takes a drop, not just the dashed cell: a drag
|
||||
* from the desktop arrives with the pointer wherever it arrives, and a target
|
||||
* seventy pixels square is one you miss. Only a file drag is claimed; the
|
||||
* panel's own photo drags carry another type and go to the sheet.
|
||||
*/
|
||||
const dropZone = canEdit && tab === 'photos' ? {
|
||||
onDragOver: (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!e.dataTransfer.types.includes('Files')) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
setDropOver(true)
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => {
|
||||
// Moving onto a child of the box is not leaving the box.
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropOver(false)
|
||||
},
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>) => {
|
||||
const files = Array.from(e.dataTransfer.files ?? [])
|
||||
if (!files.length) return
|
||||
e.preventDefault()
|
||||
setDropOver(false)
|
||||
void send(files)
|
||||
},
|
||||
} : {}
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = spread && spread.role !== 'inner' ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
}
|
||||
|
||||
const dropPhoto = (photoId: number) => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.55
|
||||
addElement(active, {
|
||||
id: uid('p'), kind: 'photo', frame: centre(side, side * 0.75),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
/**
|
||||
* One piece of the entry, dropped on the page and still attached to it.
|
||||
*
|
||||
* `format` is what separates a place name from a set of coordinates: both
|
||||
* read the entry's location, and which of the two gets printed is a property
|
||||
* of the binding rather than a second source — see the contract. `width`
|
||||
* exists because a line of coordinates arriving inside a full-column
|
||||
* selection rectangle looks like a bug; only the short marks pass it.
|
||||
*/
|
||||
const dropText = (
|
||||
value: string,
|
||||
size: number,
|
||||
weight: 400 | 700,
|
||||
entryId: number,
|
||||
kind: 'entry.title' | 'entry.story' | 'entry.location' | 'entry.date',
|
||||
opts: { format?: CoordFormat; width?: number; value?: string } = {},
|
||||
) => {
|
||||
const w = opts.width ?? page.pageWidth - 32
|
||||
addElement(active, {
|
||||
id: uid('t'), kind: 'text', frame: centre(w, size * 0.4 + 10),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
text: value, font: 'sans', size, weight, italic: false,
|
||||
align: 'left', leading: weight === 700 ? 1.1 : 1.55, tracking: weight === 700 ? -0.02 : 0,
|
||||
color: '#1a1a1a',
|
||||
binding: {
|
||||
source: kind,
|
||||
entryId,
|
||||
...(opts.format ? { format: opts.format } : {}),
|
||||
// The fact behind the words, for the marks whose words are formatted —
|
||||
// see `value` in the contract.
|
||||
...(opts.value ? { value: opts.value } : {}),
|
||||
},
|
||||
overridden: false,
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
const base = {
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, stale: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* A mood or a weather mark, drawn with the icon the journey page uses.
|
||||
*
|
||||
* `code` holds the key rather than the label, so the element carries what the
|
||||
* entry recorded and the renderer decides how to draw it — which is what lets
|
||||
* the icon and the palette come from one place.
|
||||
*/
|
||||
const dropMark = (variant: 'mood' | 'weather', code: string, label: string) =>
|
||||
addElement(active, {
|
||||
...base, id: uid('bd'), kind: 'badge', autoColor: true,
|
||||
// Set here as well as in the contract: this object is cast, not parsed.
|
||||
showIcon: true, showLabel: true, autoIconColor: true, iconColor: '#111111',
|
||||
frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.062),
|
||||
variant, text: label, sub: '', code, style: 'plain',
|
||||
} as BookElement)
|
||||
|
||||
/** Both columns of an entry's pros and cons, as one element. */
|
||||
const dropProsCons = (pros: string[], cons: string[]) => {
|
||||
const rows = Math.max(pros.length, cons.length, 1)
|
||||
addElement(active, {
|
||||
...base, id: uid('li'), kind: 'list',
|
||||
// Matches what ListView needs for `rows` lines at its largest line size,
|
||||
// so the element arrives filled rather than with a third of it empty.
|
||||
frame: centre(page.pageWidth * 0.72, Math.min(page.pageHeight * 0.6, 6 * (0.86 + 1.7 * rows))),
|
||||
items: [
|
||||
...pros.map(text => ({ text, tone: 'pro' as const })),
|
||||
...cons.map(text => ({ text, tone: 'con' as const })),
|
||||
],
|
||||
layout: 'columns', showMarks: true,
|
||||
proLabel: t('journey.editor.pros'),
|
||||
conLabel: t('journey.editor.cons'),
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head label={t('journey.studio.content')} />
|
||||
|
||||
<div className="st-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('journey.studio.searchContent')}
|
||||
aria-label={t('journey.studio.searchContent')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="st-tabs">
|
||||
<button type="button" className={tab === 'photos' ? 'is-on' : ''} onClick={() => setTab('photos')}>
|
||||
{t('journey.studio.photos')} <em>{photos.length}</em>
|
||||
</button>
|
||||
<button type="button" className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
|
||||
{t('journey.studio.entries')} <em>{entries.length}</em>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Which entry, as a row of its own under the tabs rather than a control
|
||||
inside the search box: the search answers "which words" and this
|
||||
answers "which day", and the two combine. The chip opens the same
|
||||
popover the bar uses for formats, hung from its left edge because the
|
||||
panel has no room to the right of it.
|
||||
*/}
|
||||
{tab === 'photos' && (
|
||||
<StudioContentFilter
|
||||
source={source}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
admitted={admitted.length}
|
||||
pageEntryId={spread?.entryId ?? null}
|
||||
t={t}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`st-panel-scroll ${dropOver ? 'is-drop-over' : ''}`} {...dropZone}>
|
||||
{tab === 'photos' ? (
|
||||
<>
|
||||
{canEdit && (
|
||||
<p className="st-hint st-upload-where">
|
||||
{t(filter.kind === 'entry' ? 'journey.studio.uploadToEntry' : 'journey.studio.uploadToGallery')}
|
||||
</p>
|
||||
)}
|
||||
<div className="st-photo-grid">
|
||||
{/*
|
||||
The first cell rather than a button above the grid: it is the
|
||||
size and shape of what it produces, and it stays where the eye
|
||||
already is. Disabled, not hidden, while a batch is on its way,
|
||||
so the count has somewhere to be read.
|
||||
*/}
|
||||
{canEdit && (
|
||||
<button type="button"
|
||||
className="st-photo-cell is-upload"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={!!upload}
|
||||
title={t('journey.studio.uploadHint')}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.6} />
|
||||
<span>
|
||||
{upload
|
||||
? t('journey.studio.uploading', { done: upload.done, total: upload.total })
|
||||
: t('journey.studio.uploadPhotos')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{canEdit && onBrowseProvider && providers.map(p => (
|
||||
<button type="button"
|
||||
key={p.id}
|
||||
className="st-photo-cell is-upload"
|
||||
onClick={() => { void pickFromProvider(p.id) }}
|
||||
disabled={!!upload}
|
||||
title={t('journey.studio.fromProviderHint', { name: p.name })}
|
||||
>
|
||||
<ImagePlus size={18} strokeWidth={1.6} />
|
||||
<span>{t('journey.studio.fromProvider', { name: p.name })}</span>
|
||||
</button>
|
||||
))}
|
||||
{photos.map(p => (
|
||||
<button type="button"
|
||||
key={p.photoId}
|
||||
className="st-photo-cell"
|
||||
onClick={() => dropPhoto(p.photoId)}
|
||||
title={t('journey.studio.addToPage')}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
// HTML5 drag is the right tool for exactly this shape of
|
||||
// interaction (one item, from a list, onto a target) and it
|
||||
// gives us the thumbnail as the drag image for free. The
|
||||
// canvas uses pointer events instead, because free transform
|
||||
// needs a live position that a drop event cannot provide.
|
||||
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
>
|
||||
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
|
||||
</button>
|
||||
))}
|
||||
{!photos.length && <p className="st-hint">{t(emptyKey)}</p>}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.heic,.heif"
|
||||
hidden
|
||||
onChange={e => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
// Cleared straight away, as the spread import does, so the
|
||||
// same pictures can be chosen twice in a row.
|
||||
e.target.value = ''
|
||||
void send(files)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="st-entries">
|
||||
{entries.map(e => (
|
||||
<div key={e.id} className="st-entry">
|
||||
<div className="st-entry-head">{e.title || e.location || t('journey.studio.untitled')}</div>
|
||||
{(e.date || e.location) && (
|
||||
<div className="st-entry-meta">
|
||||
{/* Two facts, two badges. Joined by a dot they read as one
|
||||
string and the eye has to parse where the date ends. The
|
||||
date follows the app's language, like every other date in
|
||||
TREK — see utils/formatters.ts. */}
|
||||
{e.date && <span className="st-badge">{formatDate(e.date, locale) ?? e.date}</span>}
|
||||
{e.location && <span className="st-badge is-quiet">{e.location}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="st-row" style={{ marginTop: 7 }}>
|
||||
{e.title && (
|
||||
<button type="button" className="st-chip" onClick={() => dropText(e.title!, 22, 700, e.id, 'entry.title')}>
|
||||
{t('journey.studio.addTitle')}
|
||||
</button>
|
||||
)}
|
||||
{e.story && (
|
||||
<button type="button" className="st-chip" onClick={() => dropText(e.story!, 10, 400, e.id, 'entry.story')}>
|
||||
{t('journey.studio.addStory')}
|
||||
</button>
|
||||
)}
|
||||
{e.location && (
|
||||
<button type="button" className="st-chip" onClick={() => dropText(e.location!, 8, 700, e.id, 'entry.location')}>
|
||||
{t('journey.studio.addPlace')}
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
The day and the point. Both are set the way the rest of the
|
||||
book sets them — the long date, coordinates in degrees — so a
|
||||
mark placed here and one the auto layout placed read alike.
|
||||
*/}
|
||||
{e.date && (
|
||||
<button type="button"
|
||||
className="st-chip"
|
||||
onClick={() => dropText(
|
||||
formatBookDate(e.date!, locale), 8, 700, e.id, 'entry.date',
|
||||
{ width: page.pageWidth * 0.36, value: e.date! },
|
||||
)}
|
||||
>
|
||||
{t('journey.studio.dateMark')}
|
||||
</button>
|
||||
)}
|
||||
{e.lat != null && e.lng != null && (
|
||||
<button type="button"
|
||||
className="st-chip"
|
||||
onClick={() => dropText(
|
||||
formatBookCoords(e.lat!, e.lng!, 'dms'), 8, 700, e.id, 'entry.location',
|
||||
{ format: 'dms', width: page.pageWidth * 0.36, value: coordValue(e.lat!, e.lng!) },
|
||||
)}
|
||||
>
|
||||
{t('journey.studio.coordsMark')}
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
What the entry recorded besides its story. Each chip shows
|
||||
the value rather than the field name — "Sunny" tells you
|
||||
what you are about to place; "Weather" does not.
|
||||
*/}
|
||||
{e.mood && MOOD_CONFIG[e.mood] && (
|
||||
<button type="button"
|
||||
className="st-chip"
|
||||
onClick={() => dropMark('mood', e.mood!, t(MOOD_CONFIG[e.mood!].label))}
|
||||
>
|
||||
{t(MOOD_CONFIG[e.mood].label)}
|
||||
</button>
|
||||
)}
|
||||
{e.weather && WEATHER_CONFIG[e.weather] && (
|
||||
<button type="button"
|
||||
className="st-chip"
|
||||
onClick={() => dropMark('weather', e.weather!, t(WEATHER_CONFIG[e.weather!].label))}
|
||||
>
|
||||
{t(WEATHER_CONFIG[e.weather].label)}
|
||||
</button>
|
||||
)}
|
||||
{(e.pros.length > 0 || e.cons.length > 0) && (
|
||||
<button type="button" className="st-chip" onClick={() => dropProsCons(e.pros, e.cons)}>
|
||||
{t('journey.studio.addProsCons')}
|
||||
<em>{e.pros.length + e.cons.length}</em>
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
Not a drop like the others: this one takes you to the
|
||||
entry's pictures. The words of a day and its pictures were
|
||||
two tabs apart with nothing joining them, and the day is
|
||||
how anyone thinks of the pictures.
|
||||
*/}
|
||||
{e.photoIds.length > 0 && (
|
||||
<button type="button"
|
||||
className="st-chip"
|
||||
onClick={() => { setFilter({ kind: 'entry', id: e.id }); setTab('photos') }}
|
||||
>
|
||||
<Camera size={12} />
|
||||
{t('journey.studio.entryPhotos')}
|
||||
<em>{e.photoIds.length}</em>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!entries.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Layouts for the current spread.
|
||||
*
|
||||
* Each card draws the arrangement itself rather than naming it — "hero + story"
|
||||
* means nothing until you have seen it, and a diagram is faster to read than a
|
||||
* label anyway.
|
||||
*/
|
||||
function TemplatesPanel({
|
||||
page, pxPerMm, t, onOpenContent,
|
||||
}: { page: BookPageSetup; pxPerMm: number; t: (k: string) => string; onOpenContent: () => void }) {
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const spread = doc?.spreads[active]
|
||||
|
||||
const list = useElementSize<HTMLDivElement>()
|
||||
const CARD_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME)
|
||||
const doc2 = doc
|
||||
const cardIsSingle = (doc2?.spreads[active]?.role ?? 'inner') !== 'inner'
|
||||
// A cover card is one page wide, not two — scaling it as a spread would draw
|
||||
// the layout at half size in the left half of the card.
|
||||
const scale = useMemo(
|
||||
() => CARD_W / (page.pageWidth * (cardIsSingle ? 1 : 2) * pxPerMm),
|
||||
[CARD_W, page.pageWidth, pxPerMm, cardIsSingle],
|
||||
)
|
||||
|
||||
if (!spread) return null
|
||||
const single = spread.role !== 'inner'
|
||||
/*
|
||||
* The cover has its own set. It used to have none — the panel said covers
|
||||
* are designed by hand, which was true and unhelpful: the cover is the page
|
||||
* people care most about and the one they had no starting point for.
|
||||
*/
|
||||
const templates = single ? COVER_TEMPLATES : TEMPLATES
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head label={t('journey.studio.templates')} count={templates.length} />
|
||||
<div className="st-panel-scroll">
|
||||
<StudioSavedLayouts page={page} width={CARD_W} pxPerMm={pxPerMm} t={t} />
|
||||
{(
|
||||
<div className="st-thumbs" ref={list.ref}>
|
||||
{templates.map(tpl => {
|
||||
const slots = tpl.build(page)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={tpl.id}
|
||||
className="st-thumb"
|
||||
onClick={() => {
|
||||
let empties = 0
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => {
|
||||
if (i !== active) return sp
|
||||
const next = applyTemplate(sp, tpl, page)
|
||||
empties = next.elements.filter(e => e.kind === 'photo' && e.photoId == null).length
|
||||
return next
|
||||
}),
|
||||
}))
|
||||
// The layout left frames waiting for a picture — so put the
|
||||
// pictures within reach instead of making the user go and
|
||||
// find the panel that holds them.
|
||||
if (empties > 0) onOpenContent()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="st-thumb-sheet"
|
||||
style={{ width: CARD_W, height: page.pageHeight * pxPerMm * scale }}
|
||||
>
|
||||
{slots.map((slot, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`st-tpl-slot is-${slot.kind}`}
|
||||
style={{
|
||||
left: slot.frame.x * pxPerMm * scale,
|
||||
top: slot.frame.y * pxPerMm * scale,
|
||||
width: slot.frame.w * pxPerMm * scale,
|
||||
height: Math.max(2, slot.frame.h * pxPerMm * scale),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{!single && (
|
||||
<span
|
||||
className="st-tpl-fold"
|
||||
style={{ left: page.pageWidth * pxPerMm * scale }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="st-thumb-label">{t(tpl.labelKey)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import { bookStickerSlotColor, type BookElement, type BookStickerElement } from '@trek/shared';
|
||||
import { bookSticker } from '@trek/shared/stickers';
|
||||
import { RotateCcw, RotateCw } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Line, NumField, Section, Switch } from './StudioControls';
|
||||
import { Line, Section } from './StudioControls';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
import { stickerLabel, turned, withSlotColor } from './stickerLibrary';
|
||||
import { stickerLabel, withSlotColor } from './stickerLibrary';
|
||||
|
||||
/**
|
||||
* The inspector's part for a sticker: its colours slot by slot, and how it
|
||||
* lies on the page (mirrored, turned, faded).
|
||||
* The inspector of a sticker: its colours slot by slot first, then where it
|
||||
* sits, which for a sticker is open from the start and holds its mirror and
|
||||
* its turn (StudioPositionSection.tsx).
|
||||
*
|
||||
* Every slot is offered with the colour it shows now, the sticker's own until
|
||||
* somebody picks another, and choosing one writes the colours of the slots
|
||||
* before it as well, so the list the document keeps always reads in slot order.
|
||||
* Turning is here as well as in the folded position numbers because a sticker
|
||||
* is the one element people set at an angle as a matter of course.
|
||||
*/
|
||||
|
||||
type Translate = (k: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** A quarter of a right angle and a little: what a sticker is tipped by, a step at a time. */
|
||||
const TURN_STEP = 15;
|
||||
|
||||
export function StudioStickerInspector({
|
||||
el,
|
||||
set,
|
||||
position,
|
||||
t,
|
||||
}: {
|
||||
el: BookStickerElement;
|
||||
set: (patch: Partial<BookElement>) => void;
|
||||
position: ReactNode;
|
||||
t: Translate;
|
||||
}) {
|
||||
const def = bookSticker(el.stickerId);
|
||||
@@ -47,7 +45,7 @@ export function StudioStickerInspector({
|
||||
</Line>
|
||||
))}
|
||||
{el.colors.length > 0 && (
|
||||
<div className="st-row" style={{ marginTop: 8 }}>
|
||||
<div className="st-row st-section-gap">
|
||||
<button type="button" className="st-chip" onClick={() => set({ colors: [] })}>
|
||||
{t('journey.studio.stickerResetColours')}
|
||||
</button>
|
||||
@@ -55,49 +53,7 @@ export function StudioStickerInspector({
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.stickerPlacement')}>
|
||||
<Switch label={t('journey.studio.stickerFlip')} on={el.flipX} onToggle={() => set({ flipX: !el.flipX })} />
|
||||
<div className="st-row" style={{ marginTop: 10, alignItems: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-chip is-icon"
|
||||
onClick={() => set({ rotation: turned(el.rotation, -TURN_STEP) })}
|
||||
title={t('journey.studio.stickerTurnLeft')}
|
||||
aria-label={t('journey.studio.stickerTurnLeft')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="st-chip is-icon"
|
||||
onClick={() => set({ rotation: turned(el.rotation, TURN_STEP) })}
|
||||
title={t('journey.studio.stickerTurnRight')}
|
||||
aria-label={t('journey.studio.stickerTurnRight')}
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.rotation')}
|
||||
value={el.rotation}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
unit="°"
|
||||
onChange={(v) => set({ rotation: v })}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.opacity')}
|
||||
value={Math.round(el.opacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
unit="%"
|
||||
onChange={(v) => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
{position}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BOOK_STICKER_THEMES, type BookPageSetup } from '@trek/shared';
|
||||
import { bookStickersOf, type BookStickerDef } from '@trek/shared/stickers';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { useMemo, useState, type DragEvent } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-stickers.css';
|
||||
import { StickerArt } from './StickerView';
|
||||
import { PanelHead } from './StudioPanelHead';
|
||||
import { Shelf } from './StudioPanelHead';
|
||||
import { StudioSearch } from './StudioSearch';
|
||||
import {
|
||||
STICKER_DRAG_TYPE,
|
||||
matchStickers,
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
} from './stickerLibrary';
|
||||
|
||||
/**
|
||||
* The sticker library, as a section of the rail: illustrations to put on a
|
||||
* page, grouped by theme, searchable in the reader's language, with the ones
|
||||
* this viewer placed last on a shelf of their own.
|
||||
* The sticker library, as the Stickers tab of the Elements panel:
|
||||
* illustrations to put on a page, grouped by theme, searchable in the
|
||||
* reader's language, with the ones this viewer placed last on a shelf of
|
||||
* their own.
|
||||
*
|
||||
* A click places a sticker in the middle of the page, a drag places it where
|
||||
* it is let go. Either way it arrives selected, so the inspector beside the
|
||||
@@ -87,44 +88,33 @@ export function StudioStickersPanel({ page, t }: { page: BookPageSetup; t: Trans
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.stickers')} count={browsing ? undefined : matches.length} />
|
||||
<div className="st-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('journey.studio.searchStickers')}
|
||||
aria-label={t('journey.studio.searchStickers')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<StudioSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
label={t('journey.studio.searchStickers')}
|
||||
clearLabel={t('common.clear')}
|
||||
count={matches.length}
|
||||
/>
|
||||
|
||||
<div className="st-panel-scroll">
|
||||
{browsing && recent.length > 0 && (
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.stickersRecent')}</div>
|
||||
<Shelf label={t('journey.studio.stickersRecent')}>
|
||||
<StickerGrid list={recent} t={t} onPick={place} />
|
||||
</div>
|
||||
</Shelf>
|
||||
)}
|
||||
|
||||
{/* Browsing, the library by theme; searching, one grid of what matches. */}
|
||||
{browsing ? (
|
||||
BOOK_STICKER_THEMES.map((id) => (
|
||||
<div className="st-section" key={id}>
|
||||
<div className="st-section-label">{stickerThemeLabel(t, id)}</div>
|
||||
<Shelf key={id} label={stickerThemeLabel(t, id)}>
|
||||
<StickerGrid list={bookStickersOf(id)} t={t} onPick={place} />
|
||||
</div>
|
||||
</Shelf>
|
||||
))
|
||||
) : (
|
||||
<div className="st-section">
|
||||
<Shelf>
|
||||
<StickerGrid list={matches} t={t} onPick={place} />
|
||||
{!matches.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
|
||||
</div>
|
||||
</Shelf>
|
||||
)}
|
||||
|
||||
<p className="st-hint">{t('journey.studio.stickersHint')}</p>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render } from '../../../tests/helpers/render';
|
||||
import { AlphaSlider } from './StudioSwatches';
|
||||
|
||||
/**
|
||||
* The opacity slider: a move is shown while it is made and committed once
|
||||
* when it is let go, and a key let go on it that moved nothing (the Tab that
|
||||
* brought the focus there) commits nothing, whatever value it last moved to.
|
||||
*/
|
||||
|
||||
function slider(value: number) {
|
||||
const onChange = vi.fn();
|
||||
const view = render(<AlphaSlider color="#112233" value={value} onChange={onChange} label="Opacity" />);
|
||||
const input = view.getByLabelText('Opacity') as HTMLInputElement;
|
||||
return { ...view, onChange, input };
|
||||
}
|
||||
|
||||
describe('the opacity slider', () => {
|
||||
it('commits nothing when the focus arrives on it with Tab', () => {
|
||||
const { input, onChange } = slider(1);
|
||||
fireEvent.keyUp(input, { key: 'Tab' });
|
||||
fireEvent.blur(input);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('commits an arrow key once, at the value it moved to', () => {
|
||||
const { input, onChange } = slider(1);
|
||||
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
||||
fireEvent.change(input, { target: { value: '99' } });
|
||||
fireEvent.keyUp(input, { key: 'ArrowLeft' });
|
||||
fireEvent.keyUp(input, { key: 'Tab' });
|
||||
expect(onChange.mock.calls).toEqual([
|
||||
[0.99, false],
|
||||
[0.99, true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not take an old move to a value given since', () => {
|
||||
const { input, onChange, rerender } = slider(0.4);
|
||||
fireEvent.pointerDown(input);
|
||||
fireEvent.change(input, { target: { value: '30' } });
|
||||
fireEvent.pointerUp(input);
|
||||
expect(onChange).toHaveBeenLastCalledWith(0.3, true);
|
||||
onChange.mockClear();
|
||||
// The same slider, now showing another colour's opacity: tabbing onto it changes nothing.
|
||||
rerender(<AlphaSlider color="#112233" value={1} onChange={onChange} label="Opacity" />);
|
||||
fireEvent.keyUp(input, { key: 'Tab' });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('commits a move the focus leaves before any key or pointer is let go', () => {
|
||||
const { input, onChange } = slider(1);
|
||||
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
||||
fireEvent.change(input, { target: { value: '80' } });
|
||||
fireEvent.blur(input);
|
||||
expect(onChange).toHaveBeenLastCalledWith(0.8, true);
|
||||
});
|
||||
});
|
||||
@@ -116,11 +116,18 @@ export function AlphaSlider({ color, value, onChange, label }: {
|
||||
const pct = Math.round(value * 100)
|
||||
// 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)
|
||||
// Whether it moved since the last commit: a key let go on it (the Tab that brought the focus) is no change.
|
||||
const dirty = useRef(false)
|
||||
const move = (raw: string) => {
|
||||
moved.current = Math.max(0, Math.min(100, Number(raw))) / 100
|
||||
dirty.current = true
|
||||
onChange(moved.current, false)
|
||||
}
|
||||
const finish = () => onChange(moved.current, true)
|
||||
const finish = () => {
|
||||
if (!dirty.current) return
|
||||
dirty.current = false
|
||||
onChange(moved.current, true)
|
||||
}
|
||||
return (
|
||||
<div className="st-alpha">
|
||||
<div className="st-alpha-track" style={{ ['--st-alpha-colour' as string]: color }}>
|
||||
@@ -136,6 +143,7 @@ export function AlphaSlider({ color, value, onChange, label }: {
|
||||
onChange={e => move(e.target.value)}
|
||||
onPointerUp={finish}
|
||||
onKeyUp={finish}
|
||||
onBlur={finish}
|
||||
/>
|
||||
</div>
|
||||
<span className="st-alpha-value">{pct}%</span>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Shapes, Type } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { StudioTabPanel, StudioTabs } from './StudioTabs';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-TABS-001 to FE-STUDIO-TABS-005: the tab rows inside Studio's
|
||||
* panels, as a screen reader and a keyboard meet them, and as a long word
|
||||
* meets them.
|
||||
*/
|
||||
|
||||
type Id = 'one' | 'two' | 'three';
|
||||
|
||||
function Row({ onChange = () => {} }: { onChange?: (id: Id) => void }) {
|
||||
const [value, setValue] = useState<Id>('one');
|
||||
return (
|
||||
<>
|
||||
<StudioTabs<Id>
|
||||
idBase="t"
|
||||
label="Kinds"
|
||||
value={value}
|
||||
columns={2}
|
||||
onChange={(id) => {
|
||||
setValue(id);
|
||||
onChange(id);
|
||||
}}
|
||||
tabs={[
|
||||
{ id: 'one', label: 'Klistermärken', icon: Type, count: 12 },
|
||||
{ id: 'two', label: 'Two', icon: Shapes },
|
||||
{ id: 'three', label: 'Three' },
|
||||
]}
|
||||
/>
|
||||
<StudioTabPanel idBase="t" value={value} className="body">
|
||||
<p>{value}</p>
|
||||
</StudioTabPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('StudioTabs', () => {
|
||||
it('FE-STUDIO-TABS-001: is a tab list naming its panel, with only the chosen tab in the Tab order', () => {
|
||||
render(<Row />);
|
||||
const list = screen.getByRole('tablist', { name: 'Kinds' });
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs.map((t) => t.getAttribute('aria-selected'))).toEqual(['true', 'false', 'false']);
|
||||
expect(tabs.map((t) => t.tabIndex)).toEqual([0, -1, -1]);
|
||||
expect(tabs[0]?.getAttribute('aria-controls')).toBe('t-panel');
|
||||
const panel = screen.getByRole('tabpanel');
|
||||
expect(panel.getAttribute('aria-labelledby')).toBe('t-tab-one');
|
||||
expect(panel.className).toBe('body');
|
||||
expect(list.className).toContain('st-subtabs');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-TABS-002: opens a tab on a click and moves the focus with the arrows without opening', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Row onChange={onChange} />);
|
||||
const [one, two, three] = screen.getAllByRole('tab');
|
||||
one?.focus();
|
||||
fireEvent.keyDown(one as HTMLElement, { key: 'ArrowRight' });
|
||||
expect(document.activeElement).toBe(two);
|
||||
fireEvent.keyDown(two as HTMLElement, { key: 'End' });
|
||||
expect(document.activeElement).toBe(three);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(three as HTMLElement);
|
||||
expect(onChange).toHaveBeenCalledWith('three');
|
||||
expect(screen.getByRole('tabpanel').textContent).toBe('three');
|
||||
expect(three?.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-TABS-005: in a grid of two lines, up and down move to the tab above or below', () => {
|
||||
render(<Row />);
|
||||
const [one, two, three] = screen.getAllByRole('tab');
|
||||
one?.focus();
|
||||
fireEvent.keyDown(one as HTMLElement, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(three);
|
||||
fireEvent.keyDown(three as HTMLElement, { key: 'ArrowUp' });
|
||||
expect(document.activeElement).toBe(one);
|
||||
two?.focus();
|
||||
// Nothing below the second tab: the focus stays.
|
||||
fireEvent.keyDown(two as HTMLElement, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(two);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-TABS-003: keeps a long word in a cell that breaks it, and the count as plain figures', () => {
|
||||
const { container } = render(<Row />);
|
||||
const label = screen.getByText('Klistermärken');
|
||||
expect(label.className).toBe('st-subtab-label');
|
||||
expect(container.querySelector('.st-subtab-count')?.textContent).toBe('12');
|
||||
// A row with icons stacks each word under its icon, so the word has the whole cell.
|
||||
expect(container.querySelector('.st-subtabs')?.classList.contains('has-icons')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-TABS-004: lays a row without icons out flat', () => {
|
||||
const { container } = render(
|
||||
<StudioTabs idBase="u" label="Plain" value="a" onChange={() => {}} tabs={[{ id: 'a', label: 'A' }]} />
|
||||
);
|
||||
expect(container.querySelector('.st-subtabs')?.classList.contains('has-icons')).toBe(false);
|
||||
expect(screen.getByRole('tab', { name: 'A' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useRef, type HTMLAttributes, type KeyboardEvent, type ReactNode } from 'react';
|
||||
|
||||
import '../../styles/studio-panels.css';
|
||||
import { rovingTarget, runsRtl } from './studioRoving';
|
||||
|
||||
/**
|
||||
* The tab rows inside Studio's panels: Content's photos and entries, the six
|
||||
* kinds of thing the Elements panel adds.
|
||||
*
|
||||
* A grid rather than a strip, so every tab keeps its whole word: a strip of six
|
||||
* cuts "Klistermärken" to "Kliste…" or wraps into a ragged second row, where a
|
||||
* grid of three across wraps into an even one. A long word breaks inside its
|
||||
* cell instead of leaving it. The count beside a label is plain figures in the
|
||||
* label's own colour, quieter than the word, never a pill inside the tab.
|
||||
*
|
||||
* The keyboard is the rail's (studioRoving.ts): the arrows move along the row,
|
||||
* Enter or Space opens the tab. In a grid of more than one line, up and down
|
||||
* move to the tab above or below.
|
||||
*/
|
||||
|
||||
interface StudioTab<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
/** How many there are behind the tab, when that is worth reading before opening it. */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export function StudioTabs<T extends string>({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
idBase,
|
||||
columns = tabs.length,
|
||||
}: {
|
||||
tabs: readonly StudioTab<T>[];
|
||||
value: T;
|
||||
onChange: (id: T) => void;
|
||||
/** The row's own name for a screen reader, which the tabs alone do not give. */
|
||||
label: string;
|
||||
/** The ids the tabs and their panel share; the panel is `${idBase}-panel`. */
|
||||
idBase: string;
|
||||
/** How many tabs share a line before the next one starts a second. */
|
||||
columns?: number;
|
||||
}) {
|
||||
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const across = Math.max(1, Math.min(columns, tabs.length));
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
const below = e.key === 'ArrowDown' ? index + across : e.key === 'ArrowUp' ? index - across : null;
|
||||
const next =
|
||||
below !== null && across < tabs.length
|
||||
? below >= 0 && below < tabs.length
|
||||
? below
|
||||
: null
|
||||
: rovingTarget(e.key, index, tabs.length, { vertical: false, rtl: runsRtl(e.currentTarget) });
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
buttons.current[next]?.focus();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`st-subtabs ${tabs.some((tab) => tab.icon) ? 'has-icons' : ''}`}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
style={{ gridTemplateColumns: `repeat(${across}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{tabs.map((tab, i) => {
|
||||
const on = tab.id === value;
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
key={tab.id}
|
||||
id={`${idBase}-tab-${tab.id}`}
|
||||
aria-selected={on}
|
||||
aria-controls={`${idBase}-panel`}
|
||||
tabIndex={on ? 0 : -1}
|
||||
className={`st-subtab ${on ? 'is-on' : ''}`}
|
||||
ref={(el) => {
|
||||
buttons.current[i] = el;
|
||||
}}
|
||||
onClick={() => onChange(tab.id)}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
>
|
||||
{Icon && <Icon size={15} strokeWidth={1.8} aria-hidden className="st-subtab-icon" />}
|
||||
<span className="st-subtab-label">{tab.label}</span>
|
||||
{tab.count != null && <span className="st-subtab-count">{tab.count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a tab row opens: the region the chosen tab names. */
|
||||
export function StudioTabPanel({
|
||||
idBase,
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
idBase: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
} & Omit<HTMLAttributes<HTMLDivElement>, 'id' | 'role' | 'className' | 'children'>) {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
id={`${idBase}-panel`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${idBase}-tab-${value}`}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,10 +41,29 @@ function mount(el: BookTextElement, photos = [{ photoId: 4 }, { photoId: 5 }]) {
|
||||
const toggle = (label: string) => screen.getByRole('button', { name: label });
|
||||
/** A number field, named by its label, followed by its value and unit. */
|
||||
const num = (label: string) => screen.getByRole('spinbutton', { name: (name) => name.startsWith(label) });
|
||||
/** The case and placement group, folded on a text that uses none of it. */
|
||||
const openPlacement = () => fireEvent.click(screen.getByRole('button', { name: 'journey.studio.fx.placement' }));
|
||||
|
||||
describe('case and placement', () => {
|
||||
it('folds away on a plain text and opens on one in small caps, set in the middle or fitted', () => {
|
||||
const plain = mount(text());
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.fx.placement' }).getAttribute('aria-expanded')).toBe(
|
||||
'false'
|
||||
);
|
||||
expect(screen.queryByRole('radio', { name: 'journey.studio.fx.caseSmall' })).toBeNull();
|
||||
plain.unmount();
|
||||
for (const over of [{ transform: 'smallcaps' }, { vAlign: 'middle' }, { fit: 'shrink' }]) {
|
||||
const used = mount(text(over));
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.fx.placement' }).getAttribute('aria-expanded')).toBe(
|
||||
'true'
|
||||
);
|
||||
used.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('sets the case and where the lines sit', () => {
|
||||
const { set } = mount(text());
|
||||
openPlacement();
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.caseSmall' }));
|
||||
expect(set).toHaveBeenCalledWith({ transform: 'smallcaps' });
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.middle' }));
|
||||
@@ -53,6 +72,7 @@ describe('case and placement', () => {
|
||||
|
||||
it('shrinks to fit, and gives the chosen size back when fitting is turned off', () => {
|
||||
const on = mount(text());
|
||||
openPlacement();
|
||||
fireEvent.click(toggle('journey.studio.fx.fit'));
|
||||
expect(on.set).toHaveBeenCalledWith({ fit: 'shrink' });
|
||||
on.unmount();
|
||||
@@ -169,6 +189,32 @@ describe('the fill of the letters', () => {
|
||||
expect(screen.getByText('journey.studio.fx.focalHint')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('turns the letters into a gradient or a pattern from their own colour, edited as a shape is', () => {
|
||||
const plain = mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' }, color: '#0f766e' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.gradient' }));
|
||||
const gradient = plain.set.mock.lastCall?.[0]?.fillPaint;
|
||||
expect(gradient?.type).toBe('linear');
|
||||
expect(gradient?.stops[0]?.color).toBe('#0f766e');
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }));
|
||||
expect(plain.set.mock.lastCall?.[0]?.fillPaint?.type).toBe('pattern');
|
||||
plain.unmount();
|
||||
|
||||
const patterned = {
|
||||
type: 'pattern' as const,
|
||||
pattern: 'dots' as const,
|
||||
fg: '#d62828',
|
||||
bg: '#ffffff',
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
};
|
||||
mount(text({ fillPaint: patterned }));
|
||||
expect(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }).getAttribute('aria-checked')).toBe(
|
||||
'true'
|
||||
);
|
||||
// Only the pattern's own controls: the kind is picked in the row above, so the paint's tabs stay away.
|
||||
expect(screen.queryByRole('tablist')).toBeNull();
|
||||
});
|
||||
|
||||
it('says so, and offers no photo fill, when the journey has no photos', () => {
|
||||
mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' } }), []);
|
||||
expect(screen.getByText('journey.studio.fx.noPhotos')).toBeTruthy();
|
||||
|
||||
@@ -8,19 +8,24 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { BookElement, BookSpread, BookTextElement } from '@trek/shared';
|
||||
import { bookTextIsDrawn } from '@trek/shared';
|
||||
import { bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
|
||||
|
||||
import '../../styles/studio-text.css';
|
||||
import { photoSrc } from './bookRender';
|
||||
import { FocalPad } from './inspector/FocalPad';
|
||||
import { textEffectsInUse, textPlacementInUse } from './inspector/inspectorModel';
|
||||
import { paintAs } from './paint/paintEdit';
|
||||
import type { PaintChannel } from './paint/usePaintChannel';
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls';
|
||||
import { FocalPad } from './StudioInspector';
|
||||
import { StudioPaintPicker } from './StudioPaintPicker';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
|
||||
|
||||
/**
|
||||
* The text inspector's effects: how the letters are cased and where the lines
|
||||
* sit, whether the text shrinks to its frame, and the outline, hard shadow,
|
||||
* marker band, curve and fill (book-text-effects.schema.ts in shared).
|
||||
* marker band, curve and fill (book-text-effects.schema.ts in shared). Both
|
||||
* groups start folded and open on a text that already uses them.
|
||||
*
|
||||
* Every effect is a switch that arrives already looking right: an outline in
|
||||
* the colour that stands out from the letters at a twentieth of their height,
|
||||
@@ -49,7 +54,7 @@ const FILL_PHOTOS = 12;
|
||||
|
||||
function CaseAndPlace({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
|
||||
return (
|
||||
<Section label={t('journey.studio.fx.placement')}>
|
||||
<Section label={t('journey.studio.fx.placement')} defaultOpen={textPlacementInUse(el)}>
|
||||
<Line label={t('journey.studio.fx.case')}>
|
||||
<Choice
|
||||
value={el.transform ?? 'none'}
|
||||
@@ -273,10 +278,13 @@ function CurveControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
|
||||
);
|
||||
}
|
||||
|
||||
type FillKind = 'colour' | 'hollow' | 'photo';
|
||||
type FillKind = 'colour' | 'hollow' | 'photo' | 'gradient' | 'pattern';
|
||||
|
||||
function fillKindOf(el: BookTextElement): FillKind {
|
||||
if (el.fillPaint?.type === 'photo') return 'photo';
|
||||
const type = el.fillPaint?.type;
|
||||
if (type === 'photo') return 'photo';
|
||||
if (type === 'linear' || type === 'radial') return 'gradient';
|
||||
if (type === 'pattern') return 'pattern';
|
||||
return isHollow(el) ? 'hollow' : 'colour';
|
||||
}
|
||||
|
||||
@@ -292,9 +300,18 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
outline: el.outline ?? { ...defaultOutline(el), color: el.color },
|
||||
} as Patch);
|
||||
}
|
||||
if (next === 'gradient' || next === 'pattern') {
|
||||
// From the text's own colour, as a shape's paint turns from its solid fill.
|
||||
return set({ fillPaint: paintAs(next, { type: 'solid', color: el.color }) } as Patch);
|
||||
}
|
||||
const photoId = firstFillPhoto(spread, photos);
|
||||
if (photoId != null) set({ fillPaint: { type: 'photo', photoId, focalX: 0.5, focalY: 0.5, zoom: 1 } } as Patch);
|
||||
};
|
||||
const letterPaint = bookTextPaintFill(el);
|
||||
const channel: PaintChannel = {
|
||||
live: (paint) => live({ fillPaint: paint } as Patch),
|
||||
commit: (paint) => set({ fillPaint: paint } as Patch),
|
||||
};
|
||||
return (
|
||||
<div className="st-fx-group">
|
||||
<Line label={t('journey.studio.fx.letters')}>
|
||||
@@ -305,11 +322,22 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
{ value: 'colour' as const, label: t('journey.studio.colour') },
|
||||
{ value: 'hollow' as const, label: t('journey.studio.fx.hollow') },
|
||||
{ value: 'photo' as const, label: t('journey.studio.kind.photo'), disabled: !photos.length },
|
||||
{ value: 'gradient' as const, label: t('journey.studio.paint.gradient') },
|
||||
{ value: 'pattern' as const, label: t('journey.studio.paint.pattern') },
|
||||
]}
|
||||
onPick={pick}
|
||||
/>
|
||||
</Line>
|
||||
{!photos.length && <p className="st-hint st-fx-hint">{t('journey.studio.fx.noPhotos')}</p>}
|
||||
{letterPaint && (kind === 'gradient' || kind === 'pattern') && (
|
||||
<StudioPaintPicker
|
||||
value={letterPaint}
|
||||
channel={channel}
|
||||
t={t}
|
||||
noneLabel={t('journey.studio.colour')}
|
||||
kinds={[kind]}
|
||||
/>
|
||||
)}
|
||||
{photo && (
|
||||
<div className="st-fx-body">
|
||||
<div className="st-fx-photos" role="radiogroup" aria-label={t('journey.studio.fx.pickPhoto')}>
|
||||
@@ -352,11 +380,10 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
|
||||
export function StudioTextEffects(props: Props) {
|
||||
const { el, set, t } = props;
|
||||
const any = !!(el.outline || el.shadow || el.highlight || el.curve || el.fillPaint);
|
||||
return (
|
||||
<>
|
||||
<CaseAndPlace el={el} set={set} t={t} />
|
||||
<Section label={t('journey.studio.fx.effects')} defaultOpen={any}>
|
||||
<Section label={t('journey.studio.fx.effects')} defaultOpen={textEffectsInUse(el)}>
|
||||
<OutlineControls el={el} set={set} t={t} />
|
||||
<ShadowControls el={el} set={set} t={t} />
|
||||
<BandControls el={el} set={set} t={t} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { BookDocument, BookTextElement } from '@trek/shared';
|
||||
import { BOOK_LOCKUPS, bookPageSetupSchema, normalizeBookDocument } from '@trek/shared';
|
||||
import { BOOK_LOCKUPS, MAX_SPREAD_ELEMENTS, bookPageSetupSchema, normalizeBookDocument } from '@trek/shared';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
@@ -73,11 +73,30 @@ describe('the Text styles shelf', () => {
|
||||
expect(elements()).toEqual([]);
|
||||
});
|
||||
|
||||
it('places nothing on a page without room for the whole set, so the book stays saveable', () => {
|
||||
const shapes = Array.from({ length: MAX_SPREAD_ELEMENTS - 2 }, (_, i) => ({
|
||||
id: `s${i}`,
|
||||
kind: 'shape',
|
||||
frame: { x: 0, y: 0, w: 5, h: 5 },
|
||||
}));
|
||||
const crowded = book();
|
||||
useStudioStore
|
||||
.getState()
|
||||
.load(normalizeBookDocument({ ...crowded, spreads: crowded.spreads.map((sp) => ({ ...sp, elements: shapes })) }));
|
||||
render(<StudioTextStyles page={page} source={source} t={t} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.lockup.circle-badge' }));
|
||||
expect(elements()).toHaveLength(MAX_SPREAD_ELEMENTS - 2);
|
||||
expect(useStudioStore.getState().canUndo()).toBe(false);
|
||||
expect(useStudioStore.getState().selection).toEqual([]);
|
||||
});
|
||||
|
||||
it('opens the Elements panel on the type tab, the plain styles above the lockups', () => {
|
||||
render(<StudioElementsPanel page={page} t={t} source={source} />);
|
||||
expect(screen.getByRole('tab', { name: 'journey.studio.text' }).getAttribute('aria-selected')).toBe('true');
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.styleTitle' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.quoteMark' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.shapes' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.shapes' }));
|
||||
expect(screen.queryByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { BookLockup, BookLockupWords, BookPageSetup } from '@trek/shared';
|
||||
import { addBookLockup, bookElementId } from '@trek/shared';
|
||||
import { MAX_SPREAD_ELEMENTS, addBookLockup, bookElementId } from '@trek/shared';
|
||||
|
||||
import { useElementSize } from '../../hooks/useElementSize';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-text.css';
|
||||
import { ElementView } from './SpreadView';
|
||||
import { Shelf } from './StudioPanelHead';
|
||||
import { firstFillPhoto } from './textEffects';
|
||||
import {
|
||||
SHELF_GROUPS,
|
||||
@@ -97,7 +98,12 @@ export function StudioTextStyles({
|
||||
|
||||
const place = (lockup: BookLockup) => {
|
||||
const els = placeLockup(lockup, { page, spread, words, gallery: source.photos, newId: bookElementId });
|
||||
if (!els.length) return;
|
||||
// The page's own cap, as a duplicate keeps it: a set that would make the book unsaveable is not placed.
|
||||
if (!els.length || !spread) return;
|
||||
if (spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
|
||||
useStudioStore.getState().refuseFull();
|
||||
return;
|
||||
}
|
||||
commit((d) => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)),
|
||||
@@ -109,8 +115,7 @@ export function StudioTextStyles({
|
||||
return (
|
||||
<>
|
||||
{SHELF_GROUPS.map((group) => (
|
||||
<div className="st-section" key={group.id}>
|
||||
<div className="st-section-label">{t(`journey.studio.lockupGroup.${group.id}`)}</div>
|
||||
<Shelf key={group.id} label={t(`journey.studio.lockupGroup.${group.id}`)}>
|
||||
<div className="st-lockups">
|
||||
{shelfLockups(group.lockups).map((lockup) => (
|
||||
<LockupTile
|
||||
@@ -123,7 +128,7 @@ export function StudioTextStyles({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
<p className="st-hint st-fx-hint">{t('journey.studio.textStylesHint')}</p>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import type {
|
||||
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
||||
BookMetric, BookStatsElement, JourneyStats,
|
||||
} from '@trek/shared'
|
||||
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
|
||||
import {
|
||||
BOOK_BADGE_STYLES, BOOK_COUNTRY_LOOKS, BOOK_METRICS, BOOK_STATS_LOOKS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch,
|
||||
} from '@trek/shared'
|
||||
import { Swatches } from './StudioSwatches'
|
||||
import { StudioFontPicker } from './StudioFontPicker'
|
||||
import { WeightLine } from './StudioTypeControls'
|
||||
@@ -14,14 +16,19 @@ import { Choice, Line, Picks, Section, Switch } from './StudioControls'
|
||||
import { fetchRoads } from './roadRoute'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { StudioThemeLink } from './theme/StudioThemeLink'
|
||||
import { mapZoomInUse, travelTypeInUse } from './inspector/inspectorModel'
|
||||
|
||||
/**
|
||||
* Properties of the travel elements.
|
||||
*
|
||||
* Its own file because these four are a different kind of thing from a photo or
|
||||
* Its own file because these five are a different kind of thing from a photo or
|
||||
* a paragraph: what you adjust is *what the element says*, not how a box looks.
|
||||
* Keeping them here leaves StudioInspector.tsx about the elements that have
|
||||
* been there since the beginning.
|
||||
* Keeping them here leaves the inspector about the elements that have been
|
||||
* there since the beginning.
|
||||
*
|
||||
* In the order every kind follows: what the element says first (a map's scope,
|
||||
* source and framing, a summary's figures, a mark's words), then where it sits,
|
||||
* then how it looks (a map's line and layers, the type, the colours).
|
||||
*/
|
||||
|
||||
export interface TravelInspectorProps {
|
||||
@@ -33,6 +40,8 @@ export interface TravelInspectorProps {
|
||||
set: (patch: Partial<BookElement>) => void
|
||||
t: (k: string) => string
|
||||
locale: string
|
||||
/** The position and size group, which every kind places after what it is. */
|
||||
position: ReactNode
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
@@ -41,7 +50,7 @@ interface Props<T> {
|
||||
t: (k: string) => string
|
||||
}
|
||||
|
||||
export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProps) {
|
||||
export function TravelInspector({ el, stats, path, set, t, position }: TravelInspectorProps) {
|
||||
const theme = useStudioStore(s => s.doc?.theme)
|
||||
if (
|
||||
el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries'
|
||||
@@ -70,12 +79,16 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{el.kind === 'map' && <MapProps el={el} set={set} t={t} stats={stats} journeyPath={path} />}
|
||||
{el.kind === 'map' && <MapContent el={el} set={set} t={t} stats={stats} journeyPath={path} />}
|
||||
{el.kind === 'stats' && <StatsProps el={el} set={set} t={t} />}
|
||||
{el.kind === 'countries' && <CountriesProps el={el} set={set} t={t} />}
|
||||
{el.kind === 'badge' && <BadgeProps el={el} set={set} t={t} />}
|
||||
{el.kind === 'list' && <ListProps el={el} set={set} t={t} />}
|
||||
|
||||
{position}
|
||||
|
||||
{el.kind === 'map' && <MapLook el={el} set={set} t={t} />}
|
||||
|
||||
{/*
|
||||
* Type, for all five alike.
|
||||
*
|
||||
@@ -83,11 +96,12 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
|
||||
* country's name over its outline — and until now the only thing you
|
||||
* could change about that was the size. A book set in a serif with its
|
||||
* stats block in the default sans is a book with one element in the
|
||||
* wrong typeface and no way to fix it.
|
||||
* wrong typeface and no way to fix it. Folded, and open on an element
|
||||
* already set in another face, weight or size.
|
||||
*/}
|
||||
<Section label={t('journey.studio.typography')} defaultOpen={false}>
|
||||
<Section label={t('journey.studio.typography')} defaultOpen={travelTypeInUse(el)}>
|
||||
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} t={t} />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="st-section-gap">
|
||||
<WeightLine
|
||||
font={el.font}
|
||||
weight={el.weight}
|
||||
@@ -147,7 +161,7 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
|
||||
onUnlink={() => set({ token: undefined } as Partial<BookElement>)} />
|
||||
</Line>
|
||||
{el.kind === 'badge' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="st-section-gap">
|
||||
<Switch
|
||||
label={t('journey.studio.autoColour')}
|
||||
on={el.autoColor}
|
||||
@@ -208,31 +222,9 @@ type MapPropsInput = Props<BookMapElement> & {
|
||||
journeyPath: [number, number][][]
|
||||
}
|
||||
|
||||
function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
/** What a map shows: which trip, where the picture comes from and its style, the framing and the zoom. */
|
||||
function MapContent({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
const cartoKey = useCartoApiKey()
|
||||
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
|
||||
const [roadDone, setRoadDone] = useState(0)
|
||||
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
|
||||
|
||||
/*
|
||||
* One leg at a time, paced, and written into the document when it is done
|
||||
* rather than leg by leg: a hundred commits would be a hundred undo steps and
|
||||
* a hundred autosaves for one action.
|
||||
*/
|
||||
const loadRoads = async () => {
|
||||
setRoadState('busy')
|
||||
setRoadDone(0)
|
||||
try {
|
||||
const roads = await fetchRoads(
|
||||
el.points.map(p => ({ lat: p.lat, lng: p.lng })),
|
||||
{ onProgress: done => setRoadDone(done) },
|
||||
)
|
||||
set({ roads } as Partial<BookElement>)
|
||||
} finally {
|
||||
setRoadState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
const sources = useMapSources(el.frame, el.points, { source: el.source, tileUrl: el.tileUrl })
|
||||
/** Only trips that put stops on the route: a map of the others is a blank. */
|
||||
const tripsWithRoute = (stats?.trips ?? []).filter(t => t.points > 0)
|
||||
@@ -328,7 +320,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
{el.source !== 'vector' && (
|
||||
<>
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>
|
||||
{t(SOURCE_HINT[sourceIdOf(el, sources)] ?? 'journey.studio.mapSourceHint')}
|
||||
{t(SOURCE_HINT[sourceIdOf(el, sources)]?.hintKey ?? 'journey.studio.mapSourceHint')}
|
||||
</p>
|
||||
{/*
|
||||
What this map will actually print at, at the size it is now.
|
||||
@@ -338,7 +330,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
{dpi != null && (
|
||||
<p className={`st-hint ${dpi < 150 ? 'st-stale' : ''}`} style={{ paddingTop: 6 }}>
|
||||
{t('journey.studio.mapPrintDpi')} {dpi} dpi
|
||||
{dpi < 150 && ` — ${t('journey.studio.mapPrintDpiLow')}`}
|
||||
{dpi < 150 && `, ${t('journey.studio.mapPrintDpiLow')}`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -408,7 +400,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
</Section>
|
||||
|
||||
{el.source === 'tiles' && (
|
||||
<Section label={t('journey.studio.mapZoom')} defaultOpen={false}>
|
||||
<Section label={t('journey.studio.mapZoom')} defaultOpen={mapZoomInUse(el)}>
|
||||
<Choice
|
||||
value={el.zoom ?? -1}
|
||||
options={[
|
||||
@@ -419,7 +411,40 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How a map looks: the line of the route, whether it follows the roads, and
|
||||
* which layers are drawn. After the position, like the look of every kind.
|
||||
*/
|
||||
function MapLook({ el, set, t }: Props<BookMapElement>) {
|
||||
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
|
||||
const [roadDone, setRoadDone] = useState(0)
|
||||
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
|
||||
|
||||
/*
|
||||
* One leg at a time, paced, and written into the document when it is done
|
||||
* rather than leg by leg: a hundred commits would be a hundred undo steps and
|
||||
* a hundred autosaves for one action.
|
||||
*/
|
||||
const loadRoads = async () => {
|
||||
setRoadState('busy')
|
||||
setRoadDone(0)
|
||||
try {
|
||||
const roads = await fetchRoads(
|
||||
el.points.map(p => ({ lat: p.lat, lng: p.lng })),
|
||||
{ onProgress: done => setRoadDone(done) },
|
||||
)
|
||||
set({ roads } as Partial<BookElement>)
|
||||
} finally {
|
||||
setRoadState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
How the line itself is drawn, and whether it follows real roads.
|
||||
|
||||
@@ -524,6 +549,8 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
options={[
|
||||
{ value: 'dot' as const, label: t('journey.studio.pinDot') },
|
||||
{ value: 'photo' as const, label: t('journey.studio.pinPhoto') },
|
||||
{ value: 'numbered' as const, label: t('journey.studio.pinNumbered') },
|
||||
{ value: 'ends' as const, label: t('journey.studio.pinEnds') },
|
||||
]}
|
||||
onPick={pinStyle => set({ pinStyle } as Partial<BookElement>)}
|
||||
/>
|
||||
@@ -574,16 +601,26 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
|
||||
/>
|
||||
</Section>
|
||||
<Section label={t('journey.studio.layout')}>
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
{/* The look first: the arrangement below belongs to the classic one alone. */}
|
||||
<Line label={t('journey.studio.look')} wide>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['grid', 'row', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
value={el.look ?? 'classic'}
|
||||
options={BOOK_STATS_LOOKS.map(look => ({ value: look, label: t(`journey.studio.statsLook.${look}`) }))}
|
||||
onPick={look => set({ look } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{(el.look ?? 'classic') === 'classic' && (
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['grid', 'row', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
<Line label={t('journey.studio.units')}>
|
||||
<Choice
|
||||
value={el.units}
|
||||
@@ -611,16 +648,25 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
|
||||
function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
|
||||
return (
|
||||
<Section label={t('journey.studio.countries')}>
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Line label={t('journey.studio.look')} wide>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['list', 'grid', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
value={el.look ?? 'classic'}
|
||||
options={BOOK_COUNTRY_LOOKS.map(look => ({ value: look, label: t(`journey.studio.countryLook.${look}`) }))}
|
||||
onPick={look => set({ look } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{(el.look ?? 'classic') === 'classic' && el.codes.length > 1 && (
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['list', 'grid', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
{/* These three read as raw English out of the code before: `left`,
|
||||
`center`, `right`, straight from the value. */}
|
||||
<Line label={t('journey.studio.align')}>
|
||||
@@ -675,9 +721,9 @@ function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
|
||||
}
|
||||
|
||||
/** What each imagery source is for, said once where it is chosen. */
|
||||
const SOURCE_HINT: Record<string, string> = {
|
||||
relief: 'journey.studio.mapSourceReliefHint',
|
||||
satellite: 'journey.studio.mapSourceSatelliteHint',
|
||||
const SOURCE_HINT: Record<string, { hintKey: string }> = {
|
||||
relief: { hintKey: 'journey.studio.mapSourceReliefHint' },
|
||||
satellite: { hintKey: 'journey.studio.mapSourceSatelliteHint' },
|
||||
}
|
||||
|
||||
/** The variants that draw something as well as saying something. */
|
||||
@@ -711,7 +757,7 @@ function BadgeProps({ el, set, t }: Props<BookBadgeElement>) {
|
||||
<Line label={t('journey.studio.style')} wide>
|
||||
<Choice
|
||||
value={el.style}
|
||||
options={(['plain', 'chip', 'outline', 'stacked'] as const).map(style => ({
|
||||
options={BOOK_BADGE_STYLES.map(style => ({
|
||||
value: style, label: t(`journey.studio.markStyle.${style}`),
|
||||
}))}
|
||||
onPick={style => set({ style } as Partial<BookElement>)}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, RouteOff } from 'lucide-react'
|
||||
import type {
|
||||
BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, BookTravelContext, JourneyStats,
|
||||
} from '@trek/shared'
|
||||
import {
|
||||
BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge,
|
||||
bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats,
|
||||
} from '@trek/shared'
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import { Globe, Hash, Map as MapIcon, Stamp } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { bookElementId as uid } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { PanelHead } from './StudioPanelHead'
|
||||
import { Section } from './StudioControls'
|
||||
import { TravelPreview } from './TravelPreview'
|
||||
import { PanelHead, Shelf } from './StudioPanelHead'
|
||||
import { StudioTabPanel, StudioTabs } from './StudioTabs'
|
||||
import { fetchRoads } from './roadRoute'
|
||||
import { TravelCard } from './travel/TravelCard'
|
||||
import { TravelStops } from './travel/TravelStops'
|
||||
import {
|
||||
COUNTRY_LOOKS, FIGURE_SHELVES, MAP_CARDS, MARK_STYLES, MARK_VARIANTS, SUMMARY_LOOKS, travelBuilders,
|
||||
} from './travel/travelBuilders'
|
||||
|
||||
/**
|
||||
* The journey's own figures, as things you can put on a page.
|
||||
@@ -21,90 +20,39 @@ import { fetchRoads } from './roadRoute'
|
||||
* distance, the countries and the dates are already in TREK; the work is
|
||||
* offering them as objects rather than as numbers someone has to retype.
|
||||
*
|
||||
* ── Why every tile is a real preview ──────────────────────────────────────
|
||||
* Four tabs, the way the Elements panel sorts what it adds: the figures, the
|
||||
* maps with the stops they are drawn from, the countries, and the marks. Every
|
||||
* card is the element its click places, built by the book engine and drawn by
|
||||
* the page's own renderer with this journey's numbers in it, in the newer
|
||||
* looks and in the book's theme (travel/travelBuilders.ts). The looks books
|
||||
* had before these still draw on the pages that hold them; they are no longer
|
||||
* offered here.
|
||||
*
|
||||
* These elements differ from one another by what they *say*, not by their
|
||||
* shape. Four map styles are four maps; three summary layouts are the same six
|
||||
* numbers arranged three ways. A named row with the value beside it — which is
|
||||
* what this panel was first — tells you none of that: you cannot tell the dark
|
||||
* map from the paper one, or a row of figures from a grid of them, until you
|
||||
* have placed one and undone it.
|
||||
*
|
||||
* So each tile builds the element it would place and renders it through the
|
||||
* page's own renderer, with this journey's real numbers in it. You are picking
|
||||
* from the things themselves.
|
||||
*
|
||||
* Every button resolves its values *now* and writes them into the element, so
|
||||
* Every card resolves its values now and writes them into the element, so
|
||||
* what lands on the page is finished rather than a placeholder that needs the
|
||||
* server to mean anything. The reasoning is in TravelElements.tsx, and the
|
||||
* short version is that the print renderer must never depend on a fetch.
|
||||
* server to mean anything: the print renderer must never depend on a fetch.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One row of the stops list: a point of the route or a stop switched off.
|
||||
*
|
||||
* `entryId` is null for a stop the server took straight from a trip's places,
|
||||
* which is what a journey has before anyone wrote an entry. Such a stop has no
|
||||
* switch, because there is no entry to put it on.
|
||||
*/
|
||||
type StopRow = {
|
||||
entryId: number | null
|
||||
label: string
|
||||
date: string | null
|
||||
country: string | null
|
||||
excluded: boolean
|
||||
}
|
||||
type Tab = 'figures' | 'maps' | 'countries' | 'marks'
|
||||
|
||||
/*
|
||||
* How many cards share a row.
|
||||
*
|
||||
* A stats panel is the width of a spread; shown two-up its figures scale down
|
||||
* past reading, so it gets the row to itself. Maps, country lists and marks are
|
||||
* compact enough that two-up still tells them apart, and two-up is what makes
|
||||
* the section scannable. The tile measures its own width — see TravelPreview.
|
||||
*/
|
||||
|
||||
/** The words a mark carries, as the book engine resolves them. */
|
||||
type BadgeValue = NonNullable<ReturnType<typeof bookBadgeValue>>
|
||||
const TABS: { id: Tab; icon: typeof Hash; labelKey: string }[] = [
|
||||
{ id: 'figures', icon: Hash, labelKey: 'journey.studio.travelFigures' },
|
||||
{ id: 'maps', icon: MapIcon, labelKey: 'journey.studio.travelMaps' },
|
||||
{ id: 'countries', icon: Globe, labelKey: 'journey.studio.countries' },
|
||||
{ id: 'marks', icon: Stamp, labelKey: 'journey.studio.marks' },
|
||||
]
|
||||
|
||||
/*
|
||||
* The two imagery maps, satellite first: the recommendation should not be the
|
||||
* second thing read. Their tiles and credit are the book engine's own
|
||||
* (BOOK_MAP_SOURCES), so a satellite map placed here is the one the assistant
|
||||
* places.
|
||||
* second thing read. Relief stops at about six hundred metres to the pixel, a
|
||||
* smudge at country size; satellite is the one that prints for the page most
|
||||
* people make, and the ring says so.
|
||||
*/
|
||||
const IMAGERY = [
|
||||
{ id: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
|
||||
{ id: 'relief', labelKey: 'journey.studio.mapSourceRelief' },
|
||||
] as const
|
||||
|
||||
function Card({ label, onClick, children, recommended = false, title }: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
/**
|
||||
* Marked as the one to reach for, with a ring rather than with words.
|
||||
*
|
||||
* The label sits in a grid cell about eighty pixels wide, where "Satellite ·
|
||||
* recommended" is two lines of ellipsis. A ring says the same thing in no
|
||||
* space at all, and the tooltip carries the word for anyone who wonders why
|
||||
* this card looks different.
|
||||
*/
|
||||
recommended?: boolean
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button type="button"
|
||||
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
|
||||
onClick={onClick}
|
||||
title={title ? `${label} — ${title}` : label}
|
||||
>
|
||||
{children}
|
||||
<span className="st-travel-card-label">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudioTravelPanel({
|
||||
page, stats, path, t, locale, canEdit, onToggleStop,
|
||||
}: {
|
||||
@@ -122,16 +70,16 @@ export function StudioTravelPanel({
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const updateElement = useStudioStore(s => s.updateElement)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
const single = !!spread && spread.role !== 'inner'
|
||||
const role = useStudioStore(s => s.doc?.spreads[active]?.role)
|
||||
const theme = useStudioStore(s => s.doc?.theme)
|
||||
const [tab, setTab] = useState<Tab>('figures')
|
||||
const tabsId = useId()
|
||||
|
||||
/*
|
||||
* The road lookups still running when the panel goes away.
|
||||
*
|
||||
* fetchRoads walks the legs at a deliberate pace, so a twenty-stop map keeps
|
||||
* asking a public router for minutes after Studio is closed, and the answer
|
||||
* lands in whatever document the store holds by then.
|
||||
* The road lookups still running when the panel goes away. fetchRoads walks
|
||||
* the legs at a deliberate pace, so a twenty-stop map keeps asking a public
|
||||
* router for minutes, and the answer would land in whatever document the
|
||||
* store holds by then.
|
||||
*/
|
||||
const roadJobs = useRef(new Set<AbortController>())
|
||||
useEffect(() => () => {
|
||||
@@ -139,22 +87,6 @@ export function StudioTravelPanel({
|
||||
roadJobs.current.clear()
|
||||
}, [])
|
||||
|
||||
/*
|
||||
* The stops whose switch is on its way to the server.
|
||||
*
|
||||
* No optimistic flip: which stop is furthest and which countries remain is
|
||||
* the server's sum, so the row reads its state from the stats the hook
|
||||
* fetches once the change has landed. Until then the chip is only held,
|
||||
* so a second click cannot send the opposite of what the first one is
|
||||
* still delivering.
|
||||
*/
|
||||
const [pending, setPending] = useState<Set<number>>(() => new Set())
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<>
|
||||
@@ -166,156 +98,17 @@ export function StudioTravelPanel({
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* What the book engine's factories read: the journey, its recorded way and
|
||||
* the words in the reader's language. Every tile below is built by the
|
||||
* factory the engine's `add` calls. Where it lands is the panel's own:
|
||||
* centred across the spread, where `add` takes the right page. The day word
|
||||
* is the engine's too, handed over as its template: `t` leaves the `{day}`
|
||||
* in it untouched, and `bookDayText` puts the number there.
|
||||
*/
|
||||
const travel: BookTravelContext = {
|
||||
locale, stats, path, entries: [],
|
||||
labels: {
|
||||
day: t('journey.studio.day'), stations: t('journey.studio.stations'), summary: t('journey.studio.summary'),
|
||||
countries: t('journey.studio.countries'), distance: t('journey.studio.metric.distance'),
|
||||
pros: t('journey.editor.pros'), cons: t('journey.editor.cons'),
|
||||
},
|
||||
}
|
||||
const at = (size: { w: number; h: number }) => centre(size.w, size.h)
|
||||
const build = travelBuilders({ page, stats, path, locale, t, theme, single: !!role && role !== 'inner' })
|
||||
|
||||
/**
|
||||
* Country names in the reader's language, not the server's.
|
||||
*
|
||||
* The API answers in English, because it has no idea who will read the book.
|
||||
* `Intl.DisplayNames` is the same CLDR data Atlas already uses for regions, so
|
||||
* a German book says "Island" and an English one says "Iceland" — from one set
|
||||
* of codes, with no translation table to keep in step.
|
||||
*/
|
||||
const countryName = (code: string): string => bookCountryName(code, locale, stats)
|
||||
|
||||
/*
|
||||
* Each builder returns the finished element. The tile renders it and the
|
||||
* click places it, so the preview and the result are the same object, and
|
||||
* the object is the engine's, so there is no second description of what a
|
||||
* card means.
|
||||
*/
|
||||
/** The linked trips that actually put stops on the route. */
|
||||
const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0)
|
||||
|
||||
/*
|
||||
* The route as rows, counting and left-out stops together.
|
||||
*
|
||||
* Every point is listed, so the list reads as the route. The left-out
|
||||
* stops sit among the counting ones in date order rather than in a list of
|
||||
* their own, because the question the section answers is "which of these
|
||||
* count?", and that is only answerable with all of them in one sequence.
|
||||
* Undated stops go last, then by name.
|
||||
*/
|
||||
const stops: StopRow[] = [
|
||||
...stats.points.map(p => ({
|
||||
entryId: p.entryId, label: p.label, date: p.date, country: p.country, excluded: false,
|
||||
})),
|
||||
...(stats.excluded ?? []).map(x => ({
|
||||
entryId: x.entryId, label: x.label, date: x.date, country: null, excluded: true,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
if (a.date !== b.date) {
|
||||
if (a.date == null) return 1
|
||||
if (b.date == null) return -1
|
||||
return a.date < b.date ? -1 : 1
|
||||
}
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
const excludedCount = stops.filter(s => s.excluded).length
|
||||
/** Whether the country is worth a badge, or the same word on every row. */
|
||||
const manyCountries = stats.countries.length > 1
|
||||
|
||||
/**
|
||||
* The day of a stop, without the weekday.
|
||||
*
|
||||
* `formatDate` is the journal's format and carries "Tue," because a journal
|
||||
* entry is about a day somebody lived. A row here is about a leg of a route,
|
||||
* and it has 236px to hold a place name in as well: the weekday costs a
|
||||
* third of the badge and answers nothing anybody asks of this list.
|
||||
*/
|
||||
const stopDay = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
|
||||
const toggleStop = async (entryId: number, excluded: boolean) => {
|
||||
setPending(prev => new Set(prev).add(entryId))
|
||||
// A refusal is the hook's to report; here it only frees the chip again.
|
||||
await onToggleStop(entryId, excluded)
|
||||
setPending(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(entryId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One country, as the entry a list would have made of it.
|
||||
*
|
||||
* The list and the grid are gone: they were compositions somebody else made,
|
||||
* deciding the order, the spacing and the type size, and the only way to
|
||||
* change any of that was to not use them. What is left is the same entry —
|
||||
* name over silhouette, set the same way — as an element you place one at a
|
||||
* time. Three of them down a page is the list, with the spacing yours; one of
|
||||
* them at 80mm is a chapter opener, which the list could never be.
|
||||
*
|
||||
* The name is editable afterwards, so numbering them "1. Germany",
|
||||
* "2. Netherlands" is typing rather than a feature.
|
||||
*/
|
||||
const countryEl = (code: string): BookElement =>
|
||||
bookTravelCountries(travel, { id: uid('co'), frame: at(bookTravelSize('countries', page)), codes: [code] })
|
||||
|
||||
/**
|
||||
* A map as the engine's `add` places it, in the style the card offers.
|
||||
*
|
||||
* The scope is the journey or one trip. A trip map carries no recorded way:
|
||||
* the trail is the journey's, and a map fitted to it would frame the whole
|
||||
* journey around one trip and lose the bow its legs are drawn with.
|
||||
*/
|
||||
const mapEl = (
|
||||
style: 'minimal' | 'outline' | 'dark' | 'paper',
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector',
|
||||
): BookElement => ({
|
||||
...bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source }),
|
||||
style,
|
||||
})
|
||||
|
||||
/** A mark at the size that suits what it holds, which is the engine's size for its variant. */
|
||||
const badgeEl = (variant: BookBadgeVariant, value: BadgeValue): BookElement =>
|
||||
bookTravelBadge({ id: uid('bd'), frame: at(bookTravelSize('badge', page, { variant })), variant, value })
|
||||
|
||||
/**
|
||||
* One figure on its own.
|
||||
*
|
||||
* A summary panel is a composition; a single figure is a mark you drop next
|
||||
* to a photograph. Both are the same element with a different metric list,
|
||||
* and offering only the composition meant anyone wanting "14 DAYS" beside a
|
||||
* picture had to place all four and delete three.
|
||||
*/
|
||||
const singleEl = (metric: BookMetric): BookElement =>
|
||||
bookTravelStats(stats, { id: uid('st'), frame: at(bookTravelSize('stats', page)), metrics: [metric] })
|
||||
|
||||
/** Place a copy — the previewed element keeps its own id for React. */
|
||||
/** Place a copy; the previewed element keeps its own id for React. */
|
||||
const place = (el: BookElement) => {
|
||||
const placed = { ...el, id: uid(el.kind[0]) } as BookElement
|
||||
const placed = { ...el, id: uid(el.kind[0] ?? 'e') } as BookElement
|
||||
addElement(active, placed)
|
||||
|
||||
/*
|
||||
* A map arrives following the roads.
|
||||
*
|
||||
* Asked for after the element is on the page rather than before, so the map
|
||||
* appears at once and the line firms up a moment later: a panel that sat
|
||||
* there for fifteen seconds after a click would read as broken, and the
|
||||
* straight line it starts with is the one it would have had anyway.
|
||||
*
|
||||
* Legs long enough to have been flights are never sent (see roadRoute.ts),
|
||||
* the router's own cache answers a second placement of the same journey for
|
||||
* free, and a failure leaves the leg exactly as it was.
|
||||
* A map arrives following the roads, asked for after it is on the page so
|
||||
* it appears at once and the line firms up a moment later. Legs long enough
|
||||
* to have been flights are never sent (roadRoute.ts), and a failure leaves
|
||||
* the leg as it was.
|
||||
*/
|
||||
if (placed.kind === 'map' && placed.points.length > 1) {
|
||||
const job = new AbortController()
|
||||
@@ -323,270 +116,228 @@ export function StudioTravelPanel({
|
||||
void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })), { signal: job.signal })
|
||||
.then(roads => {
|
||||
if (job.signal.aborted) return
|
||||
if (roads.some(r => r && r.length > 1)) {
|
||||
updateElement(active, placed.id, { roads } as Partial<BookElement>)
|
||||
}
|
||||
if (roads.some(r => r && r.length > 1)) updateElement(active, placed.id, { roads } as Partial<BookElement>)
|
||||
})
|
||||
.catch(() => { /* No roads is a state the map already draws. */ })
|
||||
.finally(() => { roadJobs.current.delete(job) })
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The marks of the journey, with the words the engine resolves for each. A
|
||||
* mark the journey cannot fill (no start date, no stop, no country) is not
|
||||
* offered.
|
||||
*/
|
||||
const marks: { el: BookElement; label: string }[] = ([
|
||||
['date', t('journey.studio.dateMark')],
|
||||
['day', t('journey.studio.dayMark')],
|
||||
['coords', t('journey.studio.coordsMark')],
|
||||
['flag', t('journey.studio.flagMark')],
|
||||
['distance', t('journey.studio.distanceMark')],
|
||||
] as const).flatMap(([variant, label]) => {
|
||||
const value = bookBadgeValue(travel, variant, null)
|
||||
return value ? [{ el: badgeEl(variant, value), label }] : []
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.travel')} />
|
||||
<div className="st-panel-scroll">
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.singleFigures')}</div>
|
||||
<div className="st-travel-grid">
|
||||
{BOOK_METRICS.map(metric => {
|
||||
const el = singleEl(metric)
|
||||
return (
|
||||
<Card key={metric} label={t(`journey.studio.metric.${metric}`)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={44} maxHeight={62} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The switch is here, beside the maps and the figures it changes, and
|
||||
not only in the entry editor: the moment somebody notices the home
|
||||
airport on the printed route is the moment they are looking at this
|
||||
panel. The rows carry no preview because they are not things to
|
||||
place; they are the input every tile below reads.
|
||||
*/}
|
||||
{stops.length > 0 && (
|
||||
<Section
|
||||
label={t('journey.studio.stops')}
|
||||
hint={t('journey.studio.stopsHint')}
|
||||
/*
|
||||
* Folded to begin with, and saying how many count while it is.
|
||||
*
|
||||
* A fortnight is fourteen rows, which is longer than everything
|
||||
* else in this panel put together, and most books never need to
|
||||
* touch it. The figure in the head is what anyone opening it came
|
||||
* to check anyway, so the closed section still answers the
|
||||
* question and the maps below stay in reach.
|
||||
*/
|
||||
defaultOpen={false}
|
||||
badge={`${stops.length - excludedCount}/${stops.length}`}
|
||||
>
|
||||
<ul className="st-stops">
|
||||
{stops.map((stop, i) => {
|
||||
const entryId = stop.entryId
|
||||
const on = !stop.excluded
|
||||
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff')
|
||||
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`
|
||||
/*
|
||||
* A mark rather than the word.
|
||||
*
|
||||
* "Counts" and "Left out" are different lengths, so a column of
|
||||
* them jitters, and together they cost a third of a row that
|
||||
* has a place name to fit. The tick and the crossed-out route
|
||||
* say the same thing at a glance, and the word is still there
|
||||
* for anyone who hovers or reads with a screen reader.
|
||||
*/
|
||||
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
|
||||
return (
|
||||
<li key={entryId ?? `place-${i}`} className={`st-stop ${on ? '' : 'is-off'}`}>
|
||||
{/*
|
||||
One line: the day, then the name, then the switch. The
|
||||
name and the day were stacked, which doubled the height
|
||||
of a list whose whole job is being read down.
|
||||
*/}
|
||||
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
|
||||
<span className="st-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
||||
{/*
|
||||
The country only where there is more than one to tell
|
||||
apart. On a journey inside one country it is the same
|
||||
word fourteen times, in the row that has least room.
|
||||
*/}
|
||||
{manyCountries && stop.country && (
|
||||
<span className="st-badge is-quiet">{countryName(stop.country)}</span>
|
||||
)}
|
||||
{canEdit && entryId != null ? (
|
||||
<button type="button"
|
||||
className={chip}
|
||||
title={`${state}: ${t('journey.studio.stopToggle')}`}
|
||||
aria-label={t('journey.studio.stopToggle')}
|
||||
aria-pressed={on}
|
||||
disabled={pending.has(entryId)}
|
||||
onClick={() => void toggleStop(entryId, !stop.excluded)}
|
||||
>
|
||||
{mark}
|
||||
</button>
|
||||
) : (
|
||||
<span className={chip} title={state} aria-label={state}>{mark}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.routeMap')}</div>
|
||||
{stats.points.length ? (
|
||||
<>
|
||||
<div className="st-travel-grid">
|
||||
{(['minimal', 'outline', 'paper', 'dark'] as const).map(style => {
|
||||
const el = mapEl(style)
|
||||
return (
|
||||
<Card key={style} label={t(`journey.studio.mapStyle.${style}`)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={62} maxHeight={80} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{/*
|
||||
The two imagery maps, as cards rather than as settings.
|
||||
|
||||
They were reachable only by placing an outline map and then
|
||||
changing its source in the inspector, which is a thing nobody
|
||||
finds. They are different pictures, not variants of one: relief
|
||||
is land shaded in its own colours against a dark sea, drawn at
|
||||
a scale that suits a continent, and satellite is the ground
|
||||
itself, which is what a page about one country wants.
|
||||
*/}
|
||||
{IMAGERY.map(src => (
|
||||
<Card
|
||||
key={src.id}
|
||||
/*
|
||||
* Satellite is marked rather than merely listed. The three
|
||||
* imagery options are not equals: relief stops at about six
|
||||
* hundred metres to the pixel, which is a continent's worth
|
||||
* of detail and a smudge at country size, and the outlines
|
||||
* are a diagram. For the page most people are making —
|
||||
* a route across one or two countries — this is the one
|
||||
* that prints, and saying so beats letting them find out
|
||||
* after the book is bound.
|
||||
*/
|
||||
label={t(src.labelKey)}
|
||||
recommended={src.id === 'satellite'}
|
||||
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
|
||||
onClick={() => place(mapEl('minimal', 'journey', src.id))}
|
||||
>
|
||||
<TravelPreview
|
||||
el={mapEl('minimal', 'journey', src.id)}
|
||||
minHeight={62}
|
||||
maxHeight={80}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A map per trip, for a journey made of more than one.
|
||||
|
||||
Two trips printed as a single route draw a line from the last
|
||||
stop of the first to the first stop of the second, which is
|
||||
usually the longest leg on the page and one nobody travelled.
|
||||
Offered rather than imposed: some journeys really are one route
|
||||
across several trips, and those still get the map above.
|
||||
|
||||
Only trips that have stops on the route are listed — a linked
|
||||
trip nobody wrote about would place an empty frame.
|
||||
*/}
|
||||
{tripsWithRoute.length > 1 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="st-section-label">{t('journey.studio.mapPerTrip')}</div>
|
||||
<div className="st-travel-grid">
|
||||
{tripsWithRoute.map(trip => {
|
||||
const el = mapEl('minimal', { tripId: trip.id })
|
||||
return (
|
||||
<Card
|
||||
key={trip.id}
|
||||
label={trip.title || t('journey.studio.untitled')}
|
||||
onClick={() => place(el)}
|
||||
>
|
||||
<TravelPreview el={el} minHeight={62} maxHeight={80} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noRoute')}</p>
|
||||
<StudioTabs<Tab>
|
||||
idBase={tabsId}
|
||||
label={t('journey.studio.travel')}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
columns={2}
|
||||
tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))}
|
||||
/>
|
||||
<StudioTabPanel idBase={tabsId} value={tab} className="st-tab-body">
|
||||
<div className="st-panel-scroll">
|
||||
{tab === 'figures' && <FiguresTab build={build} t={t} place={place} />}
|
||||
{tab === 'maps' && (
|
||||
<MapsTab build={build} stats={stats} t={t} place={place}>
|
||||
<TravelStops
|
||||
stats={stats} t={t} locale={locale} canEdit={canEdit}
|
||||
onToggleStop={onToggleStop} countryName={build.countryName}
|
||||
/>
|
||||
</MapsTab>
|
||||
)}
|
||||
{tab === 'countries' && <CountriesTab build={build} stats={stats} t={t} place={place} />}
|
||||
{tab === 'marks' && <MarksTab build={build} t={t} place={place} />}
|
||||
</div>
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.countries')}</div>
|
||||
{stats.countries.length ? (
|
||||
<>
|
||||
{/*
|
||||
One country per element, rather than a block listing them all.
|
||||
|
||||
A list of countries is a composition somebody else made: it
|
||||
decides the order, the spacing and the type size, and the only
|
||||
way to change any of it is to not use it. Placed one at a time
|
||||
they are ordinary elements — numbered, moved, set at different
|
||||
sizes, put on different pages — which is what a book of a trip
|
||||
through several countries actually wants.
|
||||
*/}
|
||||
<div className="st-travel-grid">
|
||||
{stats.countries.slice(0, 12).map(c => {
|
||||
const el = countryEl(c.code)
|
||||
return (
|
||||
<Card key={c.code} label={countryName(c.code)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={44} maxHeight={64} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
And the same country as a mark: the outline beside the name
|
||||
rather than under it, at the size of a line of type. It belongs
|
||||
next to a date or a coordinate, not on a page of its own.
|
||||
*/}
|
||||
<div className="st-travel-grid" style={{ marginTop: 6 }}>
|
||||
{stats.countries.slice(0, 12).map(c => {
|
||||
const el = badgeEl('country', bookCountryMark('country', c.code, countryName(c.code)))
|
||||
return (
|
||||
<Card key={`mark-${c.code}`} label={countryName(c.code)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={30} maxHeight={44} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noCountries')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.marks')}</div>
|
||||
<div className="st-travel-grid">
|
||||
{marks.map(({ el, label }) => (
|
||||
<Card key={el.id} label={label} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={34} maxHeight={52} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StudioTabPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type Build = ReturnType<typeof travelBuilders>
|
||||
|
||||
interface TabProps {
|
||||
build: Build
|
||||
t: (k: string) => string
|
||||
place: (el: BookElement) => void
|
||||
}
|
||||
|
||||
/** The summary in its four looks, full width, and every figure on its own as a medal. */
|
||||
function FiguresTab({ build, t, place }: TabProps) {
|
||||
return (
|
||||
<>
|
||||
<Shelf label={t('journey.studio.summary')}>
|
||||
<div className="st-travel-grid is-wide">
|
||||
{SUMMARY_LOOKS.map(look => (
|
||||
<TravelCard
|
||||
key={look}
|
||||
el={build.summaryEl(look)}
|
||||
label={t(`journey.studio.statsLook.${look}`)}
|
||||
onPlace={place}
|
||||
minHeight={look === 'band' ? 30 : 70}
|
||||
maxHeight={look === 'band' ? 40 : 120}
|
||||
inset={look === 'note' || look === 'sketch' ? 0.05 : 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
{/* Each figure on its own, in the looks that suit a single figure: a medal, an odometer, a ring drawn by hand. */}
|
||||
{FIGURE_SHELVES.map(({ look, metrics }) => (
|
||||
<Shelf key={look} label={`${t('journey.studio.singleFigures')}: ${t(`journey.studio.statsLook.${look}`)}`}>
|
||||
<div className={`st-travel-grid ${look === 'odometer' ? 'is-wide' : ''}`}>
|
||||
{metrics.map(metric => (
|
||||
<TravelCard
|
||||
key={metric}
|
||||
el={build.figureEl(metric, look)}
|
||||
label={t(`journey.studio.metric.${metric}`)}
|
||||
onPlace={place}
|
||||
minHeight={look === 'odometer' ? 50 : 80}
|
||||
maxHeight={look === 'odometer' ? 64 : 104}
|
||||
inset={look === 'odometer' ? 0 : 0.06}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The route: drawn with its photographs, its stops numbered, its two ends
|
||||
* marked or on a dark ground, then over satellite and relief imagery; a map
|
||||
* per trip for a journey of several; and the stops it is drawn from.
|
||||
*/
|
||||
function MapsTab({ build, stats, t, place, children }: TabProps & { stats: JourneyStats; children: ReactNode }) {
|
||||
if (!stats.points.length) return <p className="st-hint">{t('journey.studio.noRoute')}</p>
|
||||
// Only the trips that put stops on the route: a linked trip nobody wrote about would place an empty frame.
|
||||
const trips = (stats.trips ?? []).filter(trip => trip.points > 0)
|
||||
return (
|
||||
<>
|
||||
<Shelf label={t('journey.studio.routeMap')}>
|
||||
<div className="st-travel-grid">
|
||||
{MAP_CARDS.map(card => (
|
||||
<TravelCard key={card.id} el={build.mapEl(card)} label={t(card.labelKey)} onPlace={place} minHeight={70} maxHeight={92} />
|
||||
))}
|
||||
{IMAGERY.map(src => (
|
||||
<TravelCard
|
||||
key={src.id}
|
||||
el={build.mapEl({ style: 'minimal' }, 'journey', src.id)}
|
||||
label={t(src.labelKey)}
|
||||
onPlace={place}
|
||||
minHeight={70}
|
||||
maxHeight={92}
|
||||
recommended={src.id === 'satellite'}
|
||||
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/*
|
||||
A map per trip: two trips printed as one route draw a line from the
|
||||
last stop of one to the first of the next, a leg nobody travelled.
|
||||
*/}
|
||||
{trips.length > 1 && (
|
||||
<div className="st-shelf-sub">
|
||||
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
|
||||
<div className="st-travel-grid">
|
||||
{trips.map(trip => (
|
||||
<TravelCard
|
||||
key={trip.id}
|
||||
el={build.mapEl({ style: 'minimal', pins: 'numbered' }, { tripId: trip.id })}
|
||||
label={trip.title || t('journey.studio.untitled')}
|
||||
onPlace={place}
|
||||
minHeight={70}
|
||||
maxHeight={92}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Shelf>
|
||||
{/* Each country on its own, its stops alone, cut to its shape or in a box. */}
|
||||
{stats.countries.slice(0, 6).map(c => (
|
||||
<Shelf key={c.code} label={build.countryName(c.code)}>
|
||||
<div className="st-travel-grid">
|
||||
{COUNTRY_MAPS.map(m => (
|
||||
<TravelCard
|
||||
key={m.kind}
|
||||
el={build.countryMapEl(c.code, m.kind)}
|
||||
label={t(m.labelKey)}
|
||||
onPlace={place}
|
||||
minHeight={80}
|
||||
maxHeight={104}
|
||||
inset={m.kind === 'box' ? 0 : 0.04}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The maps of one country: cut to its shape on the paper, cut to it over the satellite picture, and in a box. */
|
||||
const COUNTRY_MAPS = [
|
||||
{ kind: 'shape', labelKey: 'journey.studio.mapClipCountry' },
|
||||
{ kind: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
|
||||
{ kind: 'box', labelKey: 'journey.studio.mapClipRect' },
|
||||
] as const
|
||||
|
||||
/** Each country in every look, one shelf per country: a stamp, a visa, a postcard, its flag, a pin, a label, a sketch. */
|
||||
function CountriesTab({ build, stats, t, place }: TabProps & { stats: JourneyStats }) {
|
||||
if (!stats.countries.length) return <p className="st-hint">{t('journey.studio.noCountries')}</p>
|
||||
return (
|
||||
<>
|
||||
{stats.countries.slice(0, 12).map(c => (
|
||||
<Shelf key={c.code} label={build.countryName(c.code)}>
|
||||
<div className="st-travel-grid">
|
||||
{COUNTRY_LOOKS.map(look => (
|
||||
<TravelCard
|
||||
key={look}
|
||||
el={build.countryEl(c.code, look)}
|
||||
label={t(`journey.studio.countryLook.${look}`)}
|
||||
onPlace={place}
|
||||
minHeight={84}
|
||||
maxHeight={100}
|
||||
inset={look === 'postcard' ? 0.04 : 0.09}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The marks the journey fills, as stamps, tags and pills. A mark it cannot fill is not offered. */
|
||||
function MarksTab({ build, t, place }: TabProps) {
|
||||
const label = (variant: (typeof MARK_VARIANTS)[number]) => t(`journey.studio.${variant}Mark`)
|
||||
return (
|
||||
<>
|
||||
{MARK_STYLES.map(({ style, variants }) => {
|
||||
const cards = variants.flatMap(variant => {
|
||||
const el = build.markEl(variant, style)
|
||||
return el ? [{ el, variant }] : []
|
||||
})
|
||||
if (!cards.length) return null
|
||||
return (
|
||||
<Shelf key={style} label={t(`journey.studio.markStyle.${style}`)}>
|
||||
<div className="st-travel-grid">
|
||||
{cards.map(({ el, variant }) => (
|
||||
<TravelCard
|
||||
key={variant}
|
||||
el={el}
|
||||
label={label(variant)}
|
||||
onPlace={place}
|
||||
minHeight={style === 'stamp' ? 56 : 38}
|
||||
maxHeight={style === 'stamp' ? 76 : 54}
|
||||
inset={style === 'stamp' || style === 'sticker' ? 0.12 : 0.08}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,40 @@ describe('a text drawn as SVG', () => {
|
||||
expect(Array.from(clip?.querySelectorAll('tspan') ?? []).map((t) => t.textContent)).toEqual(['LIS', 'BON']);
|
||||
});
|
||||
|
||||
it('fills the letters with a gradient through a gradient of their own frame', () => {
|
||||
const el = text({
|
||||
fillPaint: {
|
||||
type: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ at: 0, color: '#ff6600' },
|
||||
{ at: 1, color: '#ffcc00' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
|
||||
const gradient = container.querySelector('linearGradient');
|
||||
expect(gradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse');
|
||||
expect(Array.from(gradient?.querySelectorAll('stop') ?? []).map((s) => s.getAttribute('stop-color'))).toEqual([
|
||||
'#ff6600',
|
||||
'#ffcc00',
|
||||
]);
|
||||
const letters = container.querySelector('svg > text');
|
||||
expect(letters?.getAttribute('fill')).toBe(`url(#${gradient?.getAttribute('id')})`);
|
||||
});
|
||||
|
||||
it('fills the letters with a pattern, its ground and its marks clipped to them', () => {
|
||||
const el = text({
|
||||
fillPaint: { type: 'pattern', pattern: 'stripes', fg: '#d62828', bg: '#ffffff', scale: 1, angle: 0 },
|
||||
});
|
||||
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
|
||||
const clip = container.getElementsByTagName('clipPath')[0];
|
||||
const group = container.querySelector('g[clip-path]');
|
||||
expect(group?.getAttribute('clip-path')).toBe(`url(#${clip?.getAttribute('id')})`);
|
||||
expect(group?.querySelector('rect')?.getAttribute('fill')).toBe('#ffffff');
|
||||
expect(group?.querySelectorAll('path[fill="#d62828"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('gives every copy of a page its own ids, so a page drawn twice never clips to the other copy', () => {
|
||||
const el = text({ fillPaint: { type: 'photo', photoId: 12 }, curve: { type: 'circle' } });
|
||||
const { container } = render(
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { BookTextElement } from '@trek/shared';
|
||||
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn } from '@trek/shared';
|
||||
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
|
||||
|
||||
import { fontStack } from './bookFonts';
|
||||
import { photoSrc } from './bookRender';
|
||||
import { paintParts, type PaintParts } from './paint/paintParts';
|
||||
import {
|
||||
caseStyle,
|
||||
curveAnchor,
|
||||
@@ -145,15 +146,46 @@ interface GlyphPaint {
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
/** A gradient or pattern in the letters, laid out over the frame as a shape's paint is over its box. */
|
||||
function lettersPaint(el: BookTextElement, id: string): PaintParts | null {
|
||||
const paint = bookTextPaintFill(el);
|
||||
return paint ? paintParts(paint, { x: 0, y: 0, w: el.frame.w, h: el.frame.h }, id) : null;
|
||||
}
|
||||
|
||||
/** What the letters are filled with: the paint's fill, else their own colour. */
|
||||
function lettersFill(el: BookTextElement, paint: PaintParts | null): string {
|
||||
if (paint) return paint.fill;
|
||||
return letterFill(el) ?? el.color;
|
||||
}
|
||||
|
||||
/** A pattern's ground and its marks, clipped to the letters. */
|
||||
function PatternLetters({ parts, w, h, clipId }: { parts: PaintParts; w: number; h: number; clipId: string }) {
|
||||
const pattern = parts.pattern;
|
||||
if (!pattern) return null;
|
||||
return (
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
{parts.fill !== 'none' && <rect width={w} height={h} fill={parts.fill} />}
|
||||
<g transform={pattern.transform}>
|
||||
{pattern.layers.map((layer, i) => (
|
||||
<path key={i} d={layer.d} fill={pattern.ink} fillOpacity={layer.opacity < 1 ? layer.opacity : undefined} />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: CSSProperties; big: boolean }) {
|
||||
const reactId = useId();
|
||||
const pathId = svgId('tp-', reactId);
|
||||
const clipId = svgId('tc-', reactId);
|
||||
const paintId = svgId('tf-', reactId);
|
||||
const { w, h } = el.frame;
|
||||
const fontMm = ptToMm(el.size);
|
||||
const lines = bookDrawnLines(el);
|
||||
const curve = el.curve ? bookTextCurvePath(el.curve, w, h, fontMm) : null;
|
||||
const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null;
|
||||
const letterPaint = lettersPaint(el, paintId);
|
||||
const pattern = letterPaint ? letterPaint.pattern : null;
|
||||
const hollow = isHollow(el);
|
||||
const { outline, shadow } = el;
|
||||
|
||||
@@ -211,8 +243,10 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
|
||||
if (photo) {
|
||||
const cover = photoCover(w, h, photo);
|
||||
layers.push(<image key="photo" href={photoSrc(photo.photoId, big)} {...cover} clipPath={`url(#${clipId})`} />);
|
||||
} else if (pattern && letterPaint) {
|
||||
layers.push(<PatternLetters key="pattern" parts={letterPaint} w={w} h={h} clipId={clipId} />);
|
||||
} else {
|
||||
layers.push(glyphs({ fill: letterFill(el) ?? el.color }, 'fill'));
|
||||
layers.push(glyphs({ fill: lettersFill(el, letterPaint) }, 'fill'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -227,7 +261,8 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
|
||||
>
|
||||
<defs>
|
||||
{curve && <path id={pathId} d={curve.d} fill="none" />}
|
||||
{photo && (
|
||||
{letterPaint?.defs}
|
||||
{(photo || pattern) && (
|
||||
<clipPath id={clipId} clipPathUnits="userSpaceOnUse">
|
||||
{glyphs(null, 'clip')}
|
||||
</clipPath>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type {
|
||||
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
||||
BookMetric, BookStatsElement, BookUnits,
|
||||
BookStatsElement,
|
||||
} from '@trek/shared'
|
||||
import {
|
||||
Camera, CalendarDays, Footprints, Globe, MapPin, Navigation, Route,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { fontStack, lighterWeight, nearestWeight } from './bookFonts'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { heavy, inkAt as rgba, light, METRIC_ICONS, metricValue, round2 } from './travel/travelInk'
|
||||
import { BadgeLookView } from './travel/BadgeLooks'
|
||||
import { CountryLookView } from './travel/CountryLooks'
|
||||
import { StatsLookView } from './travel/StatsLooks'
|
||||
import { badgeIconShare, badgeInk, inkOn, isBadgeLook } from './travel/badgeLook'
|
||||
import { BadgeIcon } from './travel/BadgeIcon'
|
||||
import { EndMarker } from './travel/EndMarker'
|
||||
import { FlagMark } from './travel/FlagMark'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { brightness } from './folioColour'
|
||||
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
|
||||
import { projectOntoTiles, tileView, usableStaticUrl } from './mapTiles'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from './flags'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
@@ -71,8 +72,6 @@ function typeSize(el: TravelElement, base: number): number {
|
||||
return Math.max(1.2, el.frame.h * base * el.textScale)
|
||||
}
|
||||
|
||||
/** Two decimals of a millimetre, matching what the document stores. */
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
/** Tile geometry, kept finer than the rest: see the note where it is used. */
|
||||
const round3 = (n: number) => Math.round(n * 1000) / 1000
|
||||
|
||||
@@ -100,12 +99,6 @@ const round3 = (n: number) => Math.round(n * 1000) / 1000
|
||||
*/
|
||||
const tileOverlapMm = (size: number) => Math.max(0.8, size * 0.04)
|
||||
|
||||
/** #rrggbb plus an alpha. */
|
||||
function rgba(hex: string, alpha: number): string {
|
||||
const n = Number.parseInt(hex.slice(1), 16)
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
|
||||
}
|
||||
|
||||
export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
el: BookElement
|
||||
frameStyle: CSSProperties
|
||||
@@ -113,9 +106,22 @@ export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
big?: boolean
|
||||
}) {
|
||||
if (el.kind === 'map') return <MapView el={el} frameStyle={frameStyle} big={big} />
|
||||
if (el.kind === 'stats') return <StatsView el={el} frameStyle={frameStyle} />
|
||||
if (el.kind === 'countries') return <CountriesView el={el} frameStyle={frameStyle} />
|
||||
if (el.kind === 'badge') return <BadgeView el={el} frameStyle={frameStyle} />
|
||||
// The newer looks draw in files of their own; `classic` is what every book had before them.
|
||||
if (el.kind === 'stats') {
|
||||
return (el.look ?? 'classic') === 'classic'
|
||||
? <StatsView el={el} frameStyle={frameStyle} />
|
||||
: <StatsLookView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'countries') {
|
||||
return (el.look ?? 'classic') === 'classic'
|
||||
? <CountriesView el={el} frameStyle={frameStyle} />
|
||||
: <CountryLookView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'badge') {
|
||||
return isBadgeLook(el.style)
|
||||
? <BadgeLookView el={el} frameStyle={frameStyle} />
|
||||
: <BadgeView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'list') return <ListView el={el} frameStyle={frameStyle} />
|
||||
return null
|
||||
}
|
||||
@@ -132,17 +138,6 @@ export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
* a slab of ink for no reason. What distinguishes the four styles is how the
|
||||
* land is drawn, which is enough.
|
||||
*/
|
||||
/**
|
||||
* How heavy the type is set, and one step down for the labels under it.
|
||||
*
|
||||
* These elements draw at two levels at once — a figure over its caption, a
|
||||
* country's name over its outline — and the pair has to move together, the way
|
||||
* `textScale` moves their size. Hard-coded 700s meant the only way to lighten a
|
||||
* stats block was to not use one.
|
||||
*/
|
||||
const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight)
|
||||
const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight)
|
||||
|
||||
const MAP_PALETTES = {
|
||||
minimal: { land: '#e9e5dc', border: '#d6d0c4', onDark: false },
|
||||
outline: { land: 'transparent', border: '#b9b3a6', onDark: false },
|
||||
@@ -160,28 +155,6 @@ const MAP_PALETTES = {
|
||||
*/
|
||||
const DEFAULT_ACCENT = '#111111' // theme-lint-disable — the contract's default, matched here
|
||||
|
||||
/** Ink for a mark that picks its own, and its opposite. */
|
||||
const BADGE_DARK_INK = '#1c1b19' // theme-lint-disable — ink on a printed page, not a UI token
|
||||
const BADGE_LIGHT_INK = '#ffffff' // theme-lint-disable — paper white on a filled chip
|
||||
|
||||
/**
|
||||
* What colour a mark's words are.
|
||||
*
|
||||
* Automatic unless the element says otherwise, and what automatic means depends
|
||||
* on what the mark is: a chip carries its words on its own fill, so they answer
|
||||
* to the fill's brightness; a day or a date drawn plain takes the accent,
|
||||
* because that is the figure the page is built around. Anything else is
|
||||
* ordinary text in the text colour.
|
||||
*
|
||||
* The moment somebody picks a colour this steps aside completely. That is the
|
||||
* point of the flag: automatic is a good default and a bad cage.
|
||||
*/
|
||||
function badgeInk(el: BookBadgeElement): string {
|
||||
if (!el.autoColor) return el.color
|
||||
if (el.style === 'chip') return brightness(el.accent) > 0.55 ? BADGE_DARK_INK : BADGE_LIGHT_INK
|
||||
if (el.variant === 'day' || el.variant === 'date') return el.accent
|
||||
return el.color
|
||||
}
|
||||
function routeInk(accent: string, onDark: boolean): string {
|
||||
if (!onDark) return accent
|
||||
return accent.toLowerCase() === DEFAULT_ACCENT ? '#f2efe9' : accent // theme-lint-disable — paper on a dark map
|
||||
@@ -512,6 +485,8 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
const S = Math.min(W, H)
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
||||
const drawn = el.routeStyle === 'drawn'
|
||||
// Numbered stops and marked ends are markers of the drawn kind, whatever the line is drawn as.
|
||||
const drawnPins = drawn || el.pinStyle === 'numbered' || el.pinStyle === 'ends'
|
||||
const coreW = clamp(S * 0.0055, 0.32, 1.3)
|
||||
const casingW = coreW * 1.9
|
||||
const beadR = clamp(coreW * 3.1, 1.7, 4.8)
|
||||
@@ -528,7 +503,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
* so it inverts with it: dark on a light line, light on a dark one. Measured
|
||||
* the same way the marks decide their ink.
|
||||
*/
|
||||
const numInk = brightness(el.accent) > 0.55 ? '#1c1b19' : '#ffffff' // theme-lint-disable — print ink
|
||||
const numInk = inkOn(el.accent)
|
||||
const dashOn = coreW * 2.4
|
||||
const dashOff = coreW * 1.8
|
||||
|
||||
@@ -748,16 +723,16 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
*/
|
||||
const beads: { i: number; x: number; y: number; photo: number | null }[] = []
|
||||
let numbered = false
|
||||
if (drawn) {
|
||||
if (drawnPins) {
|
||||
// Too small to carry photographs at all: a bead wider than a twelfth of the
|
||||
// frame is a picture with a map around it.
|
||||
const roomForPhotos = el.pinStyle === 'photo' && big && beadR * 2 <= S * 0.16
|
||||
/*
|
||||
* Numbers stand in for the photographs that are not there, so they belong
|
||||
* to the photo treatment alone. A map asked for dots gets dots, and a
|
||||
* thumbnail gets dots too: two digits at 130px is a smudge.
|
||||
* Numbers stand in for the photographs that are not there, and a map asked
|
||||
* for numbers numbers every stop. A map asked for dots gets dots, and a
|
||||
* photo map's thumbnail gets dots too: two digits at 130px is a smudge.
|
||||
*/
|
||||
numbered = el.pinStyle === 'photo' && big && numR * 2 <= S * 0.12
|
||||
numbered = (el.pinStyle === 'numbered' || (el.pinStyle === 'photo' && big)) && numR * 2 <= S * 0.12
|
||||
let last: { x: number; y: number } | null = null
|
||||
route.forEach((pt, i) => {
|
||||
const photoId = (el.points[i] as { photoId?: number | null } | undefined)?.photoId ?? null
|
||||
@@ -979,7 +954,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.showPins && !drawn && route.map((p, i) => (
|
||||
{el.showPins && !drawnPins && route.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
@@ -1003,8 +978,10 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
is also what a photograph that fails to load degrades to, so the paper
|
||||
shows a deliberate marker rather than a hole.
|
||||
*/}
|
||||
{el.showPins && drawn && beads.map(m => (
|
||||
m.photo != null ? (
|
||||
{el.showPins && drawnPins && beads.map(m => (
|
||||
el.pinStyle === 'ends' && (m.i === 0 || m.i === route.length - 1) ? (
|
||||
<EndMarker key={m.i} x={m.x} y={m.y} r={numR} finish={m.i > 0} ink={coreInk} paper={numInk} />
|
||||
) : m.photo != null ? (
|
||||
<g key={m.i}>
|
||||
{/* The shadow, as two translucent discs rather than a filter. */}
|
||||
<circle cx={m.x} cy={m.y + coreW * 0.3} r={beadR + coreW * 0.55} fill="#000000" fillOpacity={0.1} />
|
||||
@@ -1127,49 +1104,6 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
|
||||
/* ── Stats ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The icon beside each figure.
|
||||
*
|
||||
* lucide, like every other icon in TREK — a hand-drawn set was the first pass
|
||||
* and it was the wrong call twice over: it put a second drawing vocabulary next
|
||||
* to the one the whole app already uses, and it meant seven paths nobody would
|
||||
* ever revisit when lucide gained a better glyph.
|
||||
*
|
||||
* They size in millimetres despite lucide taking a pixel `size`, because an
|
||||
* inline width and height override the attributes it writes while the viewBox
|
||||
* stays 24×24 — so the stroke scales with the drawing instead of thinning out
|
||||
* on a large panel.
|
||||
*/
|
||||
const METRIC_ICONS: Record<BookMetric, LucideIcon> = {
|
||||
distance: Route,
|
||||
days: CalendarDays,
|
||||
steps: Footprints,
|
||||
photos: Camera,
|
||||
countries: Globe,
|
||||
places: MapPin,
|
||||
furthest: Navigation,
|
||||
}
|
||||
|
||||
function metricValue(
|
||||
metric: BookMetric,
|
||||
values: Record<string, number>,
|
||||
units: BookUnits,
|
||||
locale: string,
|
||||
): { value: string; unit: string } {
|
||||
const raw = values[metric] ?? 0
|
||||
const n = (v: number, digits = 0) =>
|
||||
new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(v)
|
||||
|
||||
if (metric === 'distance' || metric === 'furthest') {
|
||||
// Stored in metres and converted here, so switching units never rounds a
|
||||
// second time on top of a rounded number.
|
||||
const km = raw / 1000
|
||||
const shown = units === 'imperial' ? km * 0.621371 : km
|
||||
return { value: n(shown, shown < 100 ? 1 : 0), unit: units === 'imperial' ? 'mi' : 'km' }
|
||||
}
|
||||
return { value: n(raw), unit: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip summary — the page Polarsteps' books open on, and the one that makes
|
||||
* a book feel like a record of a journey rather than a folder of pictures.
|
||||
@@ -1404,75 +1338,6 @@ function CountriesView({ el, frameStyle }: { el: BookCountriesElement; frameStyl
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A flag, drawn.
|
||||
*
|
||||
* The first version set a regional-indicator pair and let the platform draw it.
|
||||
* That is the standard trick and it is wrong for most of the people who will
|
||||
* use this: Chrome on Windows ships no flag glyphs, so it renders the two
|
||||
* letters, and "DE" printed in a photo book is not a flag.
|
||||
*
|
||||
* So flags are constructed from `flags.ts` — exact for the stripe, cross and
|
||||
* disc constructions listed there, and absent for anything with an emblem
|
||||
* rather than approximated. A country with no construction falls back to its
|
||||
* silhouette, which is correct for all 198 of them.
|
||||
*/
|
||||
function FlagMark({ code, size }: { code: string; size: number }) {
|
||||
const spec = flagSpec(code)
|
||||
const height = size
|
||||
const width = (height * FLAG_W) / FLAG_H
|
||||
|
||||
if (spec) {
|
||||
const disc = flagDisc(spec)
|
||||
return (
|
||||
<svg
|
||||
width={`${round2(width)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${FLAG_W} ${FLAG_H}`}
|
||||
style={{ display: 'block', flex: '0 0 auto', borderRadius: `${round2(height * 0.08)}mm` }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
{flagBands(spec).map((b, i) => (
|
||||
<rect key={i} x={b.x} y={b.y} width={b.w} height={b.h} fill={b.fill} />
|
||||
))}
|
||||
{disc && <circle cx={disc.cx} cy={disc.cy} r={disc.r} fill={disc.fill} />}
|
||||
{/* A hairline keeps a white-edged flag off a white page. */}
|
||||
<rect x="0" y="0" width={FLAG_W} height={FLAG_H} fill="none" stroke="rgba(0,0,0,.18)" strokeWidth="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const shape = COUNTRY_SHAPES[code.toUpperCase()]
|
||||
if (!shape) {
|
||||
// Neither a construction nor an outline: the code, set as a mark rather
|
||||
// than as two stray capitals.
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${round2(size * 0.62)}mm`,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
lineHeight: 1,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width={`${round2((height * shape.w) / shape.h)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
<path d={shape.d} fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Badges ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -1495,7 +1360,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
*/
|
||||
const big = typeSize(el, el.variant === 'date' ? 0.52 : 0.42)
|
||||
const small = typeSize(el, 0.2)
|
||||
const shape = el.code ? COUNTRY_SHAPES[el.code.toUpperCase()] : null
|
||||
const stacked = el.style === 'stacked' || el.variant === 'date'
|
||||
|
||||
const chip = el.style === 'chip'
|
||||
@@ -1511,7 +1375,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
*/
|
||||
const showIcon = el.showIcon !== false
|
||||
const showLabel = el.showLabel !== false
|
||||
const iconTint = (automatic: string) => (el.autoIconColor === false ? el.iconColor : automatic)
|
||||
/*
|
||||
* A mark with no words is a pictogram, and a pictogram should fill its box.
|
||||
* Beside a label the icon is a share of the height; on its own it takes four
|
||||
@@ -1544,54 +1407,12 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{el.variant === 'flag' && el.code && showIcon && (
|
||||
<FlagMark code={el.code} size={el.frame.h * (showLabel ? 0.5 : iconScale)} />
|
||||
)}
|
||||
|
||||
{/*
|
||||
Mood and weather come from the journal entry, and they are drawn with
|
||||
the *same* icon and the same palette the journey page uses — imported
|
||||
rather than re-listed, so a mood added to TREK appears here without
|
||||
anyone remembering to add it twice.
|
||||
The picture: a flag, an outline, or the journal's own mood and weather
|
||||
icons, imported rather than re-listed so a mood added to TREK appears
|
||||
here without anyone adding it twice.
|
||||
*/}
|
||||
{(el.variant === 'mood' || el.variant === 'weather') && el.code && showIcon && (() => {
|
||||
// Kept apart rather than narrowed out of one union: only a mood has a
|
||||
// colour of its own, and `'text' in config` types it as unknown.
|
||||
const mood = el.variant === 'mood' ? MOOD_CONFIG[el.code] : null
|
||||
const config = mood ?? WEATHER_CONFIG[el.code]
|
||||
if (!config) return null
|
||||
const Icon = config.icon
|
||||
const size = el.frame.h * iconScale
|
||||
/*
|
||||
* Automatic is the journal's own palette for a mood and the element's
|
||||
* accent for the weather. Note the colour has to be passed rather than
|
||||
* inherited: lucide writes it onto the SVG's stroke, so `currentColor`
|
||||
* from the wrapper never reaches it — which is why the Colour swatch
|
||||
* appeared to do nothing to a mood icon.
|
||||
*/
|
||||
const tint = iconTint(mood ? mood.text : el.accent)
|
||||
return (
|
||||
<Icon
|
||||
color={tint}
|
||||
strokeWidth={1.7}
|
||||
style={{ width: `${round2(size)}mm`, height: `${round2(size)}mm`, display: 'block', flex: '0 0 auto' }}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{el.variant === 'country' && shape && showIcon && (() => {
|
||||
const h = el.frame.h * (showLabel ? 0.72 : iconScale)
|
||||
return (
|
||||
<svg
|
||||
width={`${round2((h * shape.w) / shape.h)}mm`}
|
||||
height={`${round2(h)}mm`}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
>
|
||||
<path d={shape.d} fill={iconTint(el.accent)} />
|
||||
</svg>
|
||||
)
|
||||
})()}
|
||||
{showIcon && <BadgeIcon el={el} height={el.frame.h * (showLabel ? badgeIconShare(el.variant) : iconScale)} />}
|
||||
|
||||
{el.text && showLabel && (
|
||||
<span
|
||||
|
||||
@@ -27,10 +27,16 @@ import { ElementView } from './SpreadView'
|
||||
/** CSS defines 1in as 96px and 25.4mm, so this factor is exact. */
|
||||
const PX_PER_MM = 96 / 25.4
|
||||
|
||||
export function TravelPreview({ el, minHeight = 30, maxHeight = 90 }: {
|
||||
export function TravelPreview({ el, minHeight = 30, maxHeight = 90, inset = 0 }: {
|
||||
el: BookElement
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
/**
|
||||
* Room kept round the element, as a share of the tile: a stamp is set a few
|
||||
* degrees off square and a medal's ring runs to its edge, and drawn edge to
|
||||
* edge they would be clipped by the tile.
|
||||
*/
|
||||
inset?: number
|
||||
}) {
|
||||
const box = useElementSize<HTMLSpanElement>()
|
||||
const width = box.width
|
||||
@@ -48,7 +54,8 @@ export function TravelPreview({ el, minHeight = 30, maxHeight = 90 }: {
|
||||
const drawnW = el.frame.w * PX_PER_MM
|
||||
const drawnH = el.frame.h * PX_PER_MM
|
||||
const height = Math.round(Math.min(maxHeight, Math.max(minHeight, (width * drawnH) / drawnW || minHeight)))
|
||||
const scale = width > 0 ? Math.min(width / drawnW, height / drawnH) : 0
|
||||
const room = 1 - 2 * inset
|
||||
const scale = width > 0 ? Math.min((width * room) / drawnW, (height * room) / drawnH) : 0
|
||||
|
||||
return (
|
||||
<span className="st-travel-preview" style={{ height }} ref={box.ref} aria-hidden>
|
||||
|
||||
@@ -53,9 +53,12 @@ export async function warmBookFaces(
|
||||
* Warms the faces of the book the Studio holds whenever the set of faces it
|
||||
* uses changes; typing into a text does not, so an edit costs nothing here.
|
||||
*/
|
||||
/** A key compares by code units: the same faces give the same key whatever the locale. */
|
||||
const byCodeUnits = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
|
||||
export function useWarmBookFaces(): void {
|
||||
const doc = useStudioStore((s) => s.doc);
|
||||
const key = useMemo(() => (doc ? bookFacesUsed(doc).map(faceSpec).sort().join('|') : ''), [doc]);
|
||||
const key = useMemo(() => (doc ? bookFacesUsed(doc).map(faceSpec).sort(byCodeUnits).join('|') : ''), [doc]);
|
||||
useEffect(() => {
|
||||
const current = useStudioStore.getState().doc;
|
||||
// A face that will not load is drawn in its fallback, which the picker already shows; nothing to tell anyone.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useRef, type PointerEvent } from 'react';
|
||||
|
||||
import { photoSrc } from '../bookRender';
|
||||
|
||||
/**
|
||||
* Where the picture is anchored inside its frame.
|
||||
*
|
||||
* Two sliders were the first draft and they were the wrong instrument: a focal
|
||||
* point is one position in a plane, not two unrelated numbers, and nobody can
|
||||
* predict what "X 0.62" does to a photograph. Dragging the point over the
|
||||
* actual image answers the question by showing it. The pad is a picture of the
|
||||
* photograph, so it is laid out the way the photograph is, left to right in
|
||||
* every reading direction.
|
||||
*/
|
||||
export function FocalPad({
|
||||
photoId,
|
||||
x,
|
||||
y,
|
||||
onDrag,
|
||||
onCommit,
|
||||
hint,
|
||||
}: {
|
||||
photoId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
onDrag: (x: number, y: number) => void;
|
||||
onCommit: (x: number, y: number) => void;
|
||||
hint: string;
|
||||
}) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const last = useRef({ x, y });
|
||||
|
||||
const from = (e: PointerEvent) => {
|
||||
const r = box.current?.getBoundingClientRect();
|
||||
if (!r || !r.width || !r.height) return last.current;
|
||||
const nx = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
|
||||
const ny = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
|
||||
last.current = { x: nx, y: ny };
|
||||
return last.current;
|
||||
};
|
||||
|
||||
const dot = { left: `${x * 100}%`, top: `${y * 100}%` }; // rtl-lint-disable: a point on the photo
|
||||
|
||||
return (
|
||||
<div className="st-focal-wrap">
|
||||
<div
|
||||
className="st-focal"
|
||||
ref={box}
|
||||
onPointerDown={(e) => {
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
const p = from(e);
|
||||
onDrag(p.x, p.y);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!e.currentTarget.hasPointerCapture?.(e.pointerId)) return;
|
||||
const p = from(e);
|
||||
onDrag(p.x, p.y);
|
||||
}}
|
||||
onPointerUp={() => onCommit(last.current.x, last.current.y)}
|
||||
>
|
||||
<img src={photoSrc(photoId, false)} alt="" draggable={false} />
|
||||
<span className="st-focal-dot" style={dot} />
|
||||
</div>
|
||||
<p className="st-hint st-focal-hint">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { BookDocument } from '@trek/shared';
|
||||
import { bookPageSetupSchema, bookThemeOf } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import { StudioInspector } from '../StudioInspector';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-DOCINSP-001 to FE-STUDIO-DOCINSP-004: the inspector with nothing
|
||||
* selected. It says what it is for, shows the page's background, the book's
|
||||
* theme with the way to the Theme panel, and the page numbers; the page size
|
||||
* sits in its head.
|
||||
*/
|
||||
|
||||
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
|
||||
|
||||
function load(extra: Partial<BookDocument> = {}) {
|
||||
useStudioStore.getState().load({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page,
|
||||
spreads: [{ id: 'sp1', role: 'inner', background: null, elements: [], parked: [], entryId: null }],
|
||||
...extra,
|
||||
} as BookDocument);
|
||||
useStudioStore.getState().select([]);
|
||||
}
|
||||
|
||||
function draw(onOpenPanel?: (section: string) => void) {
|
||||
return render(
|
||||
<StudioInspector
|
||||
spreadIndex={0}
|
||||
page={page}
|
||||
stats={null}
|
||||
path={[]}
|
||||
source={{ entries: [], photos: [], photoEntries: {} }}
|
||||
setPageNumbers={() => {}}
|
||||
t={(k: string) => k}
|
||||
locale="en"
|
||||
onOpenPanel={onOpenPanel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = (c: HTMLElement) =>
|
||||
[...c.querySelectorAll('.st-section.is-fold > h3 .st-section-label')].map((l) => l.textContent);
|
||||
|
||||
beforeEach(() => load());
|
||||
|
||||
describe('the inspector with nothing selected', () => {
|
||||
it('FE-STUDIO-DOCINSP-001: opens on what it is for, then the page, the theme and the page numbers', () => {
|
||||
const { container } = draw();
|
||||
expect(container.querySelector('.st-what-label')?.textContent).toBe('journey.studio.document');
|
||||
expect(container.querySelector('.st-head-meta')?.textContent).toBe('journey.studio.pageHint 210 × 210 mm');
|
||||
expect(container.querySelector('.st-empty-note')?.textContent).toBe('journey.studio.inspectorEmpty');
|
||||
expect(groups(container)).toEqual([
|
||||
'journey.studio.paint.background',
|
||||
'studio.theme.panel',
|
||||
'journey.studio.pageNumbers',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-DOCINSP-002: offers a theme to a book without one, and opens the Theme panel for it', () => {
|
||||
const onOpenPanel = vi.fn();
|
||||
const { container } = draw(onOpenPanel);
|
||||
expect(container.querySelector('.st-theme-sum')).toBeNull();
|
||||
expect(screen.getByText('studio.theme.intro')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'studio.theme.choose' }));
|
||||
expect(onOpenPanel).toHaveBeenCalledWith('theme');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-DOCINSP-003: shows the theme a book has, as the panel draws it, with its name', () => {
|
||||
load({ theme: bookThemeOf('nordic') });
|
||||
const onOpenPanel = vi.fn();
|
||||
const { container } = draw(onOpenPanel);
|
||||
const sheet = container.querySelector('.st-theme-sum .st-theme-sheet') as HTMLElement | null;
|
||||
expect(sheet?.style.background).not.toBe('');
|
||||
expect(container.querySelectorAll('.st-theme-sum .st-theme-dots > span')).toHaveLength(3);
|
||||
expect(container.querySelector('.st-theme-sum-name')?.textContent).toBe('studio.theme.name.nordic');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'studio.theme.change' }));
|
||||
expect(onOpenPanel).toHaveBeenCalledWith('theme');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-DOCINSP-004: offers no way to the panel when the shell gives none', () => {
|
||||
draw();
|
||||
expect(screen.queryByRole('button', { name: 'studio.theme.choose' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { BookPageNumbers, BookPageSetup } from '@trek/shared';
|
||||
import { BOOK_THEME_PRESETS } from '@trek/shared';
|
||||
import { MousePointerClick, Palette } from 'lucide-react';
|
||||
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import '../../../styles/studio-panels.css';
|
||||
import { StudioBackgroundSection } from '../StudioBackgroundSection';
|
||||
import { Choice, Line, NumField, Section, Switch } from '../StudioControls';
|
||||
import { PanelHead } from '../StudioPanelHead';
|
||||
import type { JourneySource, StudioT } from '../StudioSidebar';
|
||||
import { Swatches } from '../StudioSwatches';
|
||||
import { ThemeSheet } from '../theme/ThemeSheet';
|
||||
|
||||
/**
|
||||
* The inspector with nothing selected: the page and the book.
|
||||
*
|
||||
* What the panel is for comes first, in a line at its top, since an empty
|
||||
* inspector is the first thing a new book shows. Then the page that is open,
|
||||
* its background; the book's theme, as the Theme panel draws it, with the way
|
||||
* to that panel; and the page numbers, which are no property of any element,
|
||||
* because the number a page carries depends on where its spread sits. The page
|
||||
* size sits in the head.
|
||||
*/
|
||||
|
||||
type Folio = BookPageNumbers['position'];
|
||||
|
||||
const FOLIO_POSITIONS: readonly Folio[] = ['outer', 'inner', 'centre'];
|
||||
|
||||
/** The book's theme as a little page and its name, and the button to the Theme panel. */
|
||||
function ThemeSummary({ t, onOpenTheme }: { t: StudioT; onOpenTheme?: () => void }) {
|
||||
const theme = useStudioStore((s) => s.doc?.theme);
|
||||
const preset = theme?.id ? BOOK_THEME_PRESETS.find((p) => p.id === theme.id) : undefined;
|
||||
return (
|
||||
<Section label={t('studio.theme.panel')}>
|
||||
{theme ? (
|
||||
<div className="st-theme-sum">
|
||||
<ThemeSheet palette={theme.palette} display={theme.fonts.display} />
|
||||
{preset && <span className="st-theme-sum-name">{t(preset.labelKey)}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="st-hint st-hint-before">{t('studio.theme.intro')}</p>
|
||||
)}
|
||||
{onOpenTheme && (
|
||||
<button type="button" className="st-chip" onClick={onOpenTheme}>
|
||||
<Palette size={13} aria-hidden />
|
||||
{t(theme ? 'studio.theme.change' : 'studio.theme.choose')}
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the pages carry numbers, and where, from which number, how large, in what colour. */
|
||||
function PageNumbersSection({
|
||||
folios,
|
||||
setPageNumbers,
|
||||
t,
|
||||
}: {
|
||||
folios: BookPageNumbers;
|
||||
setPageNumbers: (patch: Partial<BookPageNumbers>) => void;
|
||||
t: StudioT;
|
||||
}) {
|
||||
return (
|
||||
<Section label={t('journey.studio.pageNumbers')}>
|
||||
<Switch
|
||||
label={t('journey.studio.pageNumbers')}
|
||||
on={folios.show}
|
||||
onToggle={() => setPageNumbers({ show: !folios.show })}
|
||||
/>
|
||||
{folios.show && (
|
||||
<>
|
||||
<div className="st-section-gap">
|
||||
<Line label={t('journey.studio.position')}>
|
||||
<Choice
|
||||
value={folios.position}
|
||||
ariaLabel={t('journey.studio.position')}
|
||||
options={FOLIO_POSITIONS.map((pos) => ({ value: pos, label: t(`journey.studio.folio.${pos}`) }))}
|
||||
onPick={(position) => setPageNumbers({ position })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
<div className="st-grid2 st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.folioStart')}
|
||||
value={folios.startAt}
|
||||
min={0}
|
||||
max={9999}
|
||||
step={1}
|
||||
onChange={(v) => setPageNumbers({ startAt: Math.round(v) })}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.size')}
|
||||
value={folios.size}
|
||||
min={4}
|
||||
max={48}
|
||||
step={0.5}
|
||||
unit="pt"
|
||||
onChange={(v) => setPageNumbers({ size: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.folioMargin')}
|
||||
value={folios.margin}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={(v) => setPageNumbers({ margin: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="st-section-gap">
|
||||
<Switch
|
||||
label={t('journey.studio.folioAuto')}
|
||||
on={folios.autoColor}
|
||||
onToggle={() => setPageNumbers({ autoColor: !folios.autoColor })}
|
||||
/>
|
||||
</div>
|
||||
{/* Picking a colour is the other way to turn automatic off, so the row stays reachable and simply
|
||||
reads as inactive. */}
|
||||
<div className={`st-section-gap ${folios.autoColor ? 'is-quiet' : ''}`}>
|
||||
<Line label={t('journey.studio.colour')} wide>
|
||||
<Swatches value={folios.color} onPick={(c) => setPageNumbers({ color: c, autoColor: false })} />
|
||||
</Line>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function StudioDocumentInspector({
|
||||
spreadIndex,
|
||||
page,
|
||||
source,
|
||||
setPageNumbers,
|
||||
onOpenTheme,
|
||||
t,
|
||||
}: {
|
||||
spreadIndex: number;
|
||||
page: BookPageSetup;
|
||||
source: JourneySource;
|
||||
setPageNumbers: (patch: Partial<BookPageNumbers>) => void;
|
||||
/** Opens the Theme panel in the rail; without it the summary offers no button. */
|
||||
onOpenTheme?: () => void;
|
||||
t: StudioT;
|
||||
}) {
|
||||
return (
|
||||
<aside className="st-panel st-inspector">
|
||||
<PanelHead
|
||||
label={t('journey.studio.document')}
|
||||
meta={`${t('journey.studio.pageHint')} ${page.pageWidth} × ${page.pageHeight} mm`}
|
||||
/>
|
||||
<div className="st-panel-scroll">
|
||||
<p className="st-empty-note">
|
||||
<MousePointerClick size={15} aria-hidden />
|
||||
<span>{t('journey.studio.inspectorEmpty')}</span>
|
||||
</p>
|
||||
<StudioBackgroundSection spreadIndex={spreadIndex} page={page} source={source} t={t} />
|
||||
<ThemeSummary t={t} onOpenTheme={onOpenTheme} />
|
||||
<PageNumbersSection folios={page.pageNumbers} setPageNumbers={setPageNumbers} t={t} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { BookElement, BookIconElement, BookTheme } from '@trek/shared';
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
import { iconComponent, iconLabel, searchIcons } from '../iconLibrary';
|
||||
import { NumField, Section } from '../StudioControls';
|
||||
import { StudioSearch } from '../StudioSearch';
|
||||
import { Swatches } from '../StudioSwatches';
|
||||
import { StudioThemeLink } from '../theme/StudioThemeLink';
|
||||
|
||||
/** How many icons the swap grid offers at once. A search narrows it below this. */
|
||||
const ICON_CHOICES = 60;
|
||||
|
||||
/**
|
||||
* The inspector of an icon: the drawing first, swapped from a search of the
|
||||
* library, then where it sits, then its colour and the weight of its line.
|
||||
*
|
||||
* A search rather than the whole library: the inspector is a column beside a
|
||||
* page, and its job here is swapping one drawing for a near neighbour. Swapping
|
||||
* keeps the position, size and colour that deleting and re-placing would lose.
|
||||
*/
|
||||
export function StudioIconInspector({
|
||||
el,
|
||||
theme,
|
||||
set,
|
||||
position,
|
||||
t,
|
||||
}: {
|
||||
el: BookIconElement;
|
||||
theme: BookTheme | undefined;
|
||||
set: (patch: Partial<BookElement>) => void;
|
||||
position: ReactNode;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
// A page of results, memoised: the inspector re-renders on every pointer move of a drag.
|
||||
const choices = useMemo(() => searchIcons(query).slice(0, ICON_CHOICES), [query]);
|
||||
return (
|
||||
<>
|
||||
<Section label={t('journey.studio.icon')}>
|
||||
<StudioSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
label={t('journey.studio.searchIcons')}
|
||||
clearLabel={t('common.clear')}
|
||||
/>
|
||||
<div className="st-mini-shapes">
|
||||
{choices.map((name) => {
|
||||
const Glyph = iconComponent(name);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
className={`st-mini-shape ${el.name === name ? 'is-on' : ''}`}
|
||||
onClick={() => set({ name })}
|
||||
aria-label={name}
|
||||
aria-pressed={el.name === name}
|
||||
title={iconLabel(name)}
|
||||
>
|
||||
<Glyph strokeWidth={1.7} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!choices.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
|
||||
</Section>
|
||||
|
||||
{position}
|
||||
|
||||
<Section label={t('journey.studio.look')}>
|
||||
<Swatches value={el.color} onPick={(color) => set({ color })} />
|
||||
<StudioThemeLink
|
||||
theme={theme}
|
||||
token={el.token}
|
||||
t={t}
|
||||
onLink={(token, color) => set({ color, token })}
|
||||
onUnlink={() => set({ token: undefined })}
|
||||
/>
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.lineWidth')}
|
||||
value={el.lineWidth}
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.25}
|
||||
onChange={(v) => set({ lineWidth: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread } from '@trek/shared';
|
||||
import { tidyGroups, withoutGroup } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import { PhotoCropExtras, PhotoLookSections } from '../photo/StudioPhotoLook';
|
||||
import { FRAME_SHAPES, shapeName } from '../shapes';
|
||||
import { Choice, Line, NumField, Section } from '../StudioControls';
|
||||
import { EdgeSection } from '../StudioEdgeControls';
|
||||
import { ShapeGlyph } from '../StudioElementsPanel';
|
||||
import { FRAME_STYLE_OPTIONS } from '../studioOptions';
|
||||
import { FocalPad } from './FocalPad';
|
||||
import { maskInUse } from './inspectorModel';
|
||||
|
||||
/**
|
||||
* The inspector of a photo: the crop first (how the picture fills its frame,
|
||||
* where it is anchored, how far it is zoomed, and the frame filling the page),
|
||||
* then where it sits, then its look (the filter, the frame around it, the
|
||||
* corner), and folded under those the cut to a shape, the cut edge, the border
|
||||
* and shadow, and the two raster effects. Each folded group opens on a photo
|
||||
* that uses it.
|
||||
*/
|
||||
|
||||
type Set = (patch: Partial<BookElement>) => void;
|
||||
|
||||
const FITS = ['cover', 'contain'] as const;
|
||||
const FILTERS = ['none', 'bw', 'warm', 'cool', 'fade', 'contrast'] as const;
|
||||
|
||||
/**
|
||||
* Stretch the picture over the page it is on, or over both pages, bleed
|
||||
* included: the commonest layout in any photo book and the hardest to make by
|
||||
* hand, since a millimetre short leaves a white hairline on the trimmed page.
|
||||
*
|
||||
* One commit: the frame, the rotation put back to zero (a turned picture
|
||||
* leaves bare corners), the cover crop and the move to the back are one press
|
||||
* and come back with one undo. Which page is the one under the frame's centre;
|
||||
* a cover has only the one.
|
||||
*/
|
||||
function filled(sp: BookSpread, el: BookPhotoElement, page: BookPageSetup, scope: 'page' | 'spread'): BookSpread {
|
||||
const bleed = page.bleed;
|
||||
const onRight = sp.role === 'inner' && scope === 'page' && el.frame.x + el.frame.w / 2 >= page.pageWidth;
|
||||
const frame = {
|
||||
x: onRight ? page.pageWidth - bleed : -bleed,
|
||||
y: -bleed,
|
||||
w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed,
|
||||
h: page.pageHeight + 2 * bleed,
|
||||
};
|
||||
// A picture that is the page is nobody's lockup: it leaves its group, as the assistant's fill takes it out.
|
||||
const photo = { ...withoutGroup(el), frame, rotation: 0, fit: 'cover' } as BookElement;
|
||||
return tidyGroups({ ...sp, elements: [photo, ...sp.elements.filter((e) => e.id !== el.id)] });
|
||||
}
|
||||
|
||||
export function StudioPhotoInspector({
|
||||
el,
|
||||
one,
|
||||
spreadIndex,
|
||||
page,
|
||||
set,
|
||||
live,
|
||||
position,
|
||||
t,
|
||||
}: {
|
||||
el: BookPhotoElement;
|
||||
/** A single photo is selected, so the page can be filled with it. */
|
||||
one: boolean;
|
||||
spreadIndex: number;
|
||||
page: BookPageSetup;
|
||||
set: Set;
|
||||
live: Set;
|
||||
position: ReactNode;
|
||||
t: (k: string, params?: Record<string, string | number>) => string;
|
||||
}) {
|
||||
const commit = useStudioStore((s) => s.commit);
|
||||
const role = useStudioStore((s) => s.doc?.spreads[spreadIndex]?.role);
|
||||
const fill = (scope: 'page' | 'spread') =>
|
||||
commit((d) => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i === spreadIndex ? filled(sp, el, page, scope) : sp)),
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section label={t('journey.studio.crop')}>
|
||||
<Choice
|
||||
value={el.fit}
|
||||
ariaLabel={t('journey.studio.crop')}
|
||||
options={FITS.map((f) => ({ value: f, label: t(`journey.studio.fit.${f}`) }))}
|
||||
onPick={(fit) => set({ fit })}
|
||||
/>
|
||||
{/*
|
||||
An empty frame has no picture to aim at, and asking for one by id
|
||||
produced a request for /api/photos/null/thumbnail and a broken image
|
||||
under a caption telling you to drag it.
|
||||
*/}
|
||||
{el.photoId != null ? (
|
||||
<FocalPad
|
||||
photoId={el.photoId}
|
||||
x={el.focalX}
|
||||
y={el.focalY}
|
||||
onDrag={(x, y) => live({ focalX: x, focalY: y })}
|
||||
onCommit={(x, y) => set({ focalX: x, focalY: y })}
|
||||
hint={t('journey.studio.focalHint')}
|
||||
/>
|
||||
) : (
|
||||
<p className="st-hint st-hint-after">{t('journey.studio.emptyFrame')}</p>
|
||||
)}
|
||||
<PhotoCropExtras el={el} set={set} live={live} t={t} />
|
||||
{one && (
|
||||
<div className="st-row st-photo-fill">
|
||||
<button type="button" className="st-chip" onClick={() => fill('page')} title={t('journey.studio.fillHint')}>
|
||||
{t('journey.studio.fillPage')}
|
||||
</button>
|
||||
{role === 'inner' && (
|
||||
<button
|
||||
type="button"
|
||||
className="st-chip"
|
||||
onClick={() => fill('spread')}
|
||||
title={t('journey.studio.fillHint')}
|
||||
>
|
||||
{t('journey.studio.fillSpread')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{position}
|
||||
|
||||
<Section label={t('journey.studio.look')}>
|
||||
<Choice
|
||||
value={el.filter}
|
||||
ariaLabel={t('journey.studio.look')}
|
||||
options={FILTERS.map((f) => ({ value: f, label: t(`journey.studio.filter.${f}`) }))}
|
||||
onPick={(filter) => set({ filter })}
|
||||
/>
|
||||
<div className="st-section-gap">
|
||||
<Line label={t('journey.studio.frameStyle')} wide>
|
||||
<Choice
|
||||
value={el.frameStyle}
|
||||
ariaLabel={t('journey.studio.frameStyle')}
|
||||
options={FRAME_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
onPick={(frameStyle) => set({ frameStyle })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
{/* A corner radius and a mask are the same idea at different resolutions, so the radius steps
|
||||
aside once a shape is cut; it did not, and edited a property the renderer ignored. */}
|
||||
{!maskInUse(el) && (
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.radius')}
|
||||
value={el.radius}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={(v) => set({ radius: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.mask')} defaultOpen={maskInUse(el)}>
|
||||
<div className="st-mini-shapes">
|
||||
<button
|
||||
type="button"
|
||||
className={`st-mini-shape ${el.mask ? '' : 'is-on'}`}
|
||||
onClick={() => set({ mask: null })}
|
||||
title={t('journey.studio.maskNone')}
|
||||
aria-label={t('journey.studio.maskNone')}
|
||||
aria-pressed={!el.mask}
|
||||
>
|
||||
<span className="st-mini-none" />
|
||||
</button>
|
||||
{FRAME_SHAPES.filter((sh) => sh !== 'rect').map((sh) => (
|
||||
<button
|
||||
type="button"
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ mask: sh })}
|
||||
title={shapeName(sh, t)}
|
||||
aria-label={shapeName(sh, t)}
|
||||
aria-pressed={el.mask === sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<EdgeSection
|
||||
edge={el.edge}
|
||||
edgeSize={el.edgeSize}
|
||||
outline={maskInUse(el) ? (el.mask ?? null) : null}
|
||||
onSet={(patch) => set(patch)}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PhotoLookSections el={el} set={set} live={live} t={t} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { BookElement } from '@trek/shared';
|
||||
import { RotateCcw, RotateCw } from 'lucide-react';
|
||||
|
||||
import { NumField, Section, Switch } from '../StudioControls';
|
||||
import { turned } from '../stickerLibrary';
|
||||
import { positionInUse } from './inspectorModel';
|
||||
|
||||
/**
|
||||
* Where the selected element sits, how big it is, how it is turned and how
|
||||
* strongly it shows: one group, the same for every kind.
|
||||
*
|
||||
* Folded unless the element is turned or faded, or is a sticker: everything in
|
||||
* here can be done by dragging, which is how it is actually done, and the
|
||||
* numbers are for the one time in ten you need the same margin on two pages.
|
||||
* The turn buttons tip the element a step at a time, which a sticker or a
|
||||
* photo set at an angle wants more often than a typed figure. A sticker's
|
||||
* mirror is here as well, since it is how the drawing lies on the page.
|
||||
*
|
||||
* With several selected the group keeps the angle, the opacity, the turn and,
|
||||
* when they are all stickers, the mirror, and each writes to every one of
|
||||
* them. Only the frame's numbers go: one x for four elements would stack them
|
||||
* on top of each other. The sticker inspector used to carry its own turn,
|
||||
* angle and opacity beside these, so a sticker had two of each.
|
||||
*/
|
||||
|
||||
/** A quarter of a right angle and a little: what the turn buttons tip an element by. */
|
||||
const TURN_STEP = 15;
|
||||
|
||||
export function StudioPositionSection({
|
||||
el,
|
||||
sel,
|
||||
set,
|
||||
t,
|
||||
}: {
|
||||
/** The first of the selection, whose figures the group shows. */
|
||||
el: BookElement;
|
||||
/** The whole selection, `el` among it. */
|
||||
sel: readonly BookElement[];
|
||||
set: (patch: Partial<BookElement>) => void;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const { frame } = el;
|
||||
const one = sel.length <= 1;
|
||||
const turnLeft = t('journey.studio.stickerTurnLeft');
|
||||
const turnRight = t('journey.studio.stickerTurnRight');
|
||||
const mirror = el.kind === 'sticker' && sel.every((e) => e.kind === 'sticker');
|
||||
return (
|
||||
<Section label={t('journey.studio.position')} defaultOpen={sel.some((e) => positionInUse(e))}>
|
||||
{one && (
|
||||
<div className="st-grid2">
|
||||
<NumField label="X" value={frame.x} unit="mm" onChange={(v) => set({ frame: { ...frame, x: v } })} />
|
||||
<NumField label="Y" value={frame.y} unit="mm" onChange={(v) => set({ frame: { ...frame, y: v } })} />
|
||||
<NumField
|
||||
label={t('journey.studio.width')}
|
||||
value={frame.w}
|
||||
min={4}
|
||||
unit="mm"
|
||||
onChange={(v) => set({ frame: { ...frame, w: v } })}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.height')}
|
||||
value={frame.h}
|
||||
min={4}
|
||||
unit="mm"
|
||||
onChange={(v) => set({ frame: { ...frame, h: v } })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`st-grid2 ${one ? 'st-position-row' : ''}`}>
|
||||
<NumField
|
||||
label={t('journey.studio.rotation')}
|
||||
value={el.rotation}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
unit="°"
|
||||
onChange={(v) => set({ rotation: v })}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.opacity')}
|
||||
value={Math.round(el.opacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
unit="%"
|
||||
onChange={(v) => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="st-row st-position-row">
|
||||
<button
|
||||
type="button"
|
||||
className="st-chip is-icon"
|
||||
onClick={() => set({ rotation: turned(el.rotation, -TURN_STEP) })}
|
||||
title={turnLeft}
|
||||
aria-label={turnLeft}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="st-chip is-icon"
|
||||
onClick={() => set({ rotation: turned(el.rotation, TURN_STEP) })}
|
||||
title={turnRight}
|
||||
aria-label={turnRight}
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{mirror && (
|
||||
<Switch label={t('journey.studio.stickerFlip')} on={el.flipX} onToggle={() => set({ flipX: !el.flipX })} />
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { BookElement, BookShapeElement, BookShapeId, BookTheme } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { SHAPE_GROUPS, shapeName } from '../shapes';
|
||||
import { NumField, Section } from '../StudioControls';
|
||||
import { EdgeSection } from '../StudioEdgeControls';
|
||||
import { ShapeGlyph } from '../StudioElementsPanel';
|
||||
import { StudioShapeFill, StudioShapeOutline } from '../StudioShapePaint';
|
||||
|
||||
/**
|
||||
* The inspector of a shape: which shape it is and its corner first, then where
|
||||
* it sits, then its fill, and folded under that its outline and its cut edge,
|
||||
* each open on a shape that has one.
|
||||
*/
|
||||
export function StudioShapeInspector({
|
||||
el,
|
||||
spreadIndex,
|
||||
selection,
|
||||
theme,
|
||||
set,
|
||||
position,
|
||||
t,
|
||||
}: {
|
||||
el: BookShapeElement;
|
||||
spreadIndex: number;
|
||||
selection: readonly string[];
|
||||
theme: BookTheme | undefined;
|
||||
set: (patch: Partial<BookElement>) => void;
|
||||
position: ReactNode;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Section label={t('journey.studio.shape')}>
|
||||
{/* Every shape, not just the two the element started as: swapping a placed shape keeps its position,
|
||||
size and colour, which deleting and re-adding would not. */}
|
||||
<div className="st-mini-shapes">
|
||||
{SHAPE_GROUPS.flatMap((g) => g.shapes).map((sh: BookShapeId) => (
|
||||
<button
|
||||
type="button"
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ shape: sh })}
|
||||
title={shapeName(sh, t)}
|
||||
aria-label={shapeName(sh, t)}
|
||||
aria-pressed={el.shape === sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.radius')}
|
||||
value={el.radius}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={(v) => set({ radius: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{position}
|
||||
|
||||
<StudioShapeFill el={el} spreadIndex={spreadIndex} selection={selection} theme={theme} set={set} t={t} />
|
||||
<StudioShapeOutline el={el} theme={theme} set={set} t={t} />
|
||||
<EdgeSection edge={el.edge} edgeSize={el.edgeSize} outline={el.shape} onSet={(patch) => set(patch)} t={t} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { BookElement, BookSpread, BookTextElement, BookTheme } from '@trek/shared';
|
||||
import { MAX_BOOK_TEXT_SIZE, formatBookCoords } from '@trek/shared';
|
||||
import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Italic } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { italicTitle } from '../fontPicker';
|
||||
import { Choice, Line, NumField, Section } from '../StudioControls';
|
||||
import { StudioFontPicker } from '../StudioFontPicker';
|
||||
import type { JourneySource } from '../StudioSidebar';
|
||||
import { Swatches } from '../StudioSwatches';
|
||||
import { StudioTextEffects } from '../StudioTextEffects';
|
||||
import { WeightLine } from '../StudioTypeControls';
|
||||
import { StudioThemeLink } from '../theme/StudioThemeLink';
|
||||
|
||||
/**
|
||||
* The inspector of a text: its words first, then where it sits, then how it is
|
||||
* set (the typeface, its size and spacing, the alignment), its colour, and the
|
||||
* rarer settings folded under them: the case and the place of the lines in the
|
||||
* frame, and the effects. Each folded group opens on a text that uses it.
|
||||
*/
|
||||
|
||||
type Set = (patch: Partial<BookElement>) => void;
|
||||
|
||||
const ALIGNS = [
|
||||
{ value: 'left', icon: AlignLeft, labelKey: 'journey.studio.align.left' },
|
||||
{ value: 'center', icon: AlignCenter, labelKey: 'journey.studio.align.center' },
|
||||
{ value: 'right', icon: AlignRight, labelKey: 'journey.studio.align.right' },
|
||||
{ value: 'justify', icon: AlignJustify, labelKey: 'journey.studio.align.justify' },
|
||||
] as const;
|
||||
|
||||
/** What the text says, and for a bound coordinate, how the point is written. */
|
||||
function TextContent({
|
||||
el,
|
||||
set,
|
||||
live,
|
||||
source,
|
||||
t,
|
||||
}: {
|
||||
el: BookTextElement;
|
||||
set: Set;
|
||||
live: Set;
|
||||
source: JourneySource;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const binding = el.binding;
|
||||
return (
|
||||
<Section label={t('journey.studio.text')}>
|
||||
<textarea
|
||||
className="st-input st-textarea"
|
||||
value={el.text}
|
||||
rows={5}
|
||||
aria-label={t('journey.studio.text')}
|
||||
onChange={(e) => live({ text: e.target.value, overridden: true })}
|
||||
onBlur={(e) => set({ text: e.target.value, overridden: true })}
|
||||
/>
|
||||
{binding && !el.overridden && <p className="st-hint st-hint-after">{t('journey.studio.boundHint')}</p>}
|
||||
{/*
|
||||
How a point is set. Only a coordinate binding has the question, and
|
||||
answering it re-sets the words on the spot rather than at the next open.
|
||||
Deliberately not `overridden`: choosing a format is not writing the text.
|
||||
*/}
|
||||
{binding?.source === 'entry.location' && binding.format && (
|
||||
<div className="st-section-gap">
|
||||
<Line label={t('journey.studio.coordsMark')}>
|
||||
<Choice
|
||||
value={binding.format}
|
||||
ariaLabel={t('journey.studio.coordsMark')}
|
||||
options={[
|
||||
{ value: 'dms' as const, label: t('journey.studio.coordsDms') },
|
||||
{ value: 'decimal' as const, label: t('journey.studio.coordsDecimal') },
|
||||
]}
|
||||
onPick={(format) => {
|
||||
const entry = source.entries.find((e) => e.id === binding.entryId);
|
||||
const text =
|
||||
entry?.lat != null && entry.lng != null ? formatBookCoords(entry.lat, entry.lng, format) : el.text;
|
||||
set({ binding: { ...binding, format }, text });
|
||||
}}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/** How the words are set: each family set in itself, its weight, size, spacing and alignment. */
|
||||
function TextType({ el, set, t }: { el: BookTextElement; set: Set; t: (k: string) => string }) {
|
||||
return (
|
||||
<Section label={t('journey.studio.typography')}>
|
||||
{/* Each family set in itself, because a list of names tells you nothing about a typeface. */}
|
||||
<StudioFontPicker font={el.font} weight={el.weight} onPick={(patch) => set(patch)} t={t} />
|
||||
<div className="st-section-gap">
|
||||
<WeightLine font={el.font} weight={el.weight} onPick={(weight) => set({ weight })} t={t} />
|
||||
</div>
|
||||
<div className="st-grid2 st-section-gap">
|
||||
<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.leading')}
|
||||
value={el.leading}
|
||||
min={0.7}
|
||||
max={3}
|
||||
step={0.05}
|
||||
onChange={(v) => set({ leading: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.tracking')}
|
||||
value={el.tracking}
|
||||
min={-0.2}
|
||||
max={1.5}
|
||||
step={0.01}
|
||||
unit="em"
|
||||
onChange={(v) => set({ tracking: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="st-section-gap">
|
||||
<Line label={t('journey.studio.align')}>
|
||||
<div className="st-row">
|
||||
{ALIGNS.map(({ value, icon: Icon, labelKey }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={value}
|
||||
className={`st-chip is-icon ${el.align === value ? 'is-on' : ''}`}
|
||||
onClick={() => set({ align: value })}
|
||||
aria-label={t(labelKey)}
|
||||
title={t(labelKey)}
|
||||
aria-pressed={el.align === value}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
))}
|
||||
{/* Italic sits with the alignment because both are things you do to a line rather than to a typeface. */}
|
||||
<button
|
||||
type="button"
|
||||
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
|
||||
onClick={() => set({ italic: !el.italic })}
|
||||
title={italicTitle(el.font, el.weight, t)}
|
||||
aria-label={t('journey.studio.italic')}
|
||||
aria-pressed={el.italic}
|
||||
>
|
||||
<Italic size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Line>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function StudioTextInspector({
|
||||
el,
|
||||
set,
|
||||
live,
|
||||
source,
|
||||
spread,
|
||||
theme,
|
||||
position,
|
||||
t,
|
||||
}: {
|
||||
el: BookTextElement;
|
||||
/** One undo step. */
|
||||
set: Set;
|
||||
/** A change while typing or dragging. */
|
||||
live: Set;
|
||||
source: JourneySource;
|
||||
spread: BookSpread | undefined;
|
||||
theme: BookTheme | undefined;
|
||||
/** The position and size group, which every kind places after what it is. */
|
||||
position: ReactNode;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TextContent el={el} set={set} live={live} source={source} t={t} />
|
||||
{position}
|
||||
<TextType el={el} set={set} t={t} />
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
<Swatches value={el.color} onPick={(color) => set({ color })} />
|
||||
<StudioThemeLink
|
||||
theme={theme}
|
||||
token={el.token}
|
||||
t={t}
|
||||
onLink={(token, color) => set({ color, token })}
|
||||
onUnlink={() => set({ token: undefined })}
|
||||
/>
|
||||
</Section>
|
||||
<StudioTextEffects el={el} set={set} live={live} photos={source.photos} spread={spread} t={t} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { BookLineElement, BookPhotoElement, BookShapeElement, BookTextElement } from '@trek/shared';
|
||||
import {
|
||||
bookLineElementSchema,
|
||||
bookMapElementSchema,
|
||||
bookPhotoElementSchema,
|
||||
bookShapeElementSchema,
|
||||
bookStatsElementSchema,
|
||||
bookStickerElementSchema,
|
||||
bookTextElementSchema,
|
||||
} from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
edgeInUse,
|
||||
mapZoomInUse,
|
||||
maskInUse,
|
||||
ornamentInUse,
|
||||
photoEdgesInUse,
|
||||
positionInUse,
|
||||
selectionKey,
|
||||
shapeOutlineInUse,
|
||||
textEffectsInUse,
|
||||
textPlacementInUse,
|
||||
travelTypeInUse,
|
||||
} from './inspectorModel';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-INSPMODEL-001 to FE-STUDIO-INSPMODEL-008: which folded groups of
|
||||
* the inspector an element already uses, read off the element alone.
|
||||
*/
|
||||
|
||||
const FRAME = { x: 10, y: 10, w: 40, h: 30 };
|
||||
const text = (over: Record<string, unknown> = {}): BookTextElement =>
|
||||
bookTextElementSchema.parse({ id: 't', kind: 'text', frame: FRAME, text: 'Lisbon', ...over });
|
||||
const photo = (over: Record<string, unknown> = {}): BookPhotoElement =>
|
||||
bookPhotoElementSchema.parse({ id: 'p', kind: 'photo', frame: FRAME, photoId: 3, ...over });
|
||||
const shape = (over: Record<string, unknown> = {}): BookShapeElement =>
|
||||
bookShapeElementSchema.parse({ id: 's', kind: 'shape', frame: FRAME, ...over });
|
||||
const line = (over: Record<string, unknown> = {}): BookLineElement =>
|
||||
bookLineElementSchema.parse({ id: 'l', kind: 'line', frame: FRAME, ...over });
|
||||
|
||||
describe('the groups an element uses', () => {
|
||||
it('FE-STUDIO-INSPMODEL-001: opens the position on a turned, faded or sticker element only', () => {
|
||||
expect(positionInUse(shape())).toBe(false);
|
||||
expect(positionInUse(shape({ rotation: 4 }))).toBe(true);
|
||||
expect(positionInUse(shape({ opacity: 0.6 }))).toBe(true);
|
||||
const sticker = bookStickerElementSchema.parse({ id: 'k', kind: 'sticker', frame: FRAME, stickerId: 'sun' });
|
||||
expect(positionInUse(sticker)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPMODEL-002: reads case, the lines in the frame and fitting as the placement of a text', () => {
|
||||
expect(textPlacementInUse(text())).toBe(false);
|
||||
expect(textPlacementInUse(text({ transform: 'none', vAlign: 'top', fit: 'none' }))).toBe(false);
|
||||
expect(textPlacementInUse(text({ transform: 'upper' }))).toBe(true);
|
||||
expect(textPlacementInUse(text({ vAlign: 'bottom' }))).toBe(true);
|
||||
expect(textPlacementInUse(text({ fit: 'shrink' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPMODEL-003: counts any one effect as the effects of a text', () => {
|
||||
expect(textEffectsInUse(text())).toBe(false);
|
||||
expect(textEffectsInUse(text({ outline: { color: '#ffffff', width: 0.5 } }))).toBe(true);
|
||||
expect(textEffectsInUse(text({ curve: { type: 'arc', bend: 40, start: 0 } }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPMODEL-004: reads a drawn or dashed stroke as the outline of a shape', () => {
|
||||
expect(shapeOutlineInUse(shape({ stroke: null, strokeWidth: 0 }))).toBe(false);
|
||||
expect(shapeOutlineInUse(shape({ stroke: '#141414', strokeWidth: 0 }))).toBe(false);
|
||||
expect(shapeOutlineInUse(shape({ stroke: '#141414', strokeWidth: 0.5 }))).toBe(true);
|
||||
expect(shapeOutlineInUse(shape({ strokeStyle: 'dashed' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPMODEL-005: reads the cut, the edge, the border and the ornament off the element', () => {
|
||||
expect(edgeInUse(undefined)).toBe(false);
|
||||
expect(edgeInUse('straight')).toBe(false);
|
||||
expect(edgeInUse('zigzag')).toBe(true);
|
||||
expect(maskInUse(photo())).toBe(false);
|
||||
expect(maskInUse(photo({ mask: 'rect' }))).toBe(false);
|
||||
expect(maskInUse(photo({ mask: 'heart' }))).toBe(true);
|
||||
expect(photoEdgesInUse(photo())).toBe(false);
|
||||
expect(photoEdgesInUse(photo({ border: { width: 3, color: '#ffffff' } }))).toBe(true);
|
||||
expect(ornamentInUse(line())).toBe(false);
|
||||
expect(ornamentInUse(line({ ornament: 'sun' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPMODEL-006: keys the groups by the selection, so another selection mounts them afresh', () => {
|
||||
expect(selectionKey(['a'])).toBe('a');
|
||||
expect(selectionKey(['a', 'b'])).toBe('a|b');
|
||||
expect(selectionKey(['a', 'b'])).not.toBe(selectionKey(['b', 'a']));
|
||||
expect(selectionKey([])).toBe('');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPMODEL-007: reads a zoom of its own as the zoom of a map', () => {
|
||||
const map = (over: Record<string, unknown> = {}) =>
|
||||
bookMapElementSchema.parse({ id: 'm', kind: 'map', frame: FRAME, source: 'tiles', ...over });
|
||||
expect(mapZoomInUse(map())).toBe(false);
|
||||
expect(mapZoomInUse(map({ zoom: null }))).toBe(false);
|
||||
expect(mapZoomInUse(map({ zoom: 11 }))).toBe(true);
|
||||
expect(mapZoomInUse(map({ zoom: 0 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPMODEL-008: reads another face, weight or size as the type of a travel element', () => {
|
||||
const stats = (over: Record<string, unknown> = {}) =>
|
||||
bookStatsElementSchema.parse({ id: 'st', kind: 'stats', frame: FRAME, ...over });
|
||||
expect(travelTypeInUse(stats())).toBe(false);
|
||||
expect(travelTypeInUse(stats({ font: 'serif' }))).toBe(true);
|
||||
expect(travelTypeInUse(stats({ weight: 400 }))).toBe(true);
|
||||
expect(travelTypeInUse(stats({ textScale: 0.85 }))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
BookElement,
|
||||
BookLineElement,
|
||||
BookMapElement,
|
||||
BookPhotoElement,
|
||||
BookShapeElement,
|
||||
BookStatsElement,
|
||||
BookTextElement,
|
||||
} from '@trek/shared';
|
||||
import { photoLookOf } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Which of the inspector's folded groups an element already uses.
|
||||
*
|
||||
* The groups people set once and rarely return to (the frame's numbers, the
|
||||
* effects, a photo's duotone) start folded, so the panel opens on what is
|
||||
* worth reading. A group the element already uses opens anyway: a turned
|
||||
* sticker shows its angle, a text in small caps shows its case, a photo with
|
||||
* a border shows its border. The panel remembers nothing between selections,
|
||||
* so the answer is always read off the element itself.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The position and size: open on an element that is turned or faded, and on
|
||||
* a sticker, which people set at an angle as a matter of course.
|
||||
*/
|
||||
export function positionInUse(el: BookElement): boolean {
|
||||
return el.kind === 'sticker' || el.rotation !== 0 || el.opacity < 1;
|
||||
}
|
||||
|
||||
/** Case, the place of the lines in the frame and shrinking to fit: anything but upright lines at the top. */
|
||||
export function textPlacementInUse(el: BookTextElement): boolean {
|
||||
return (el.transform ?? 'none') !== 'none' || (el.vAlign ?? 'top') !== 'top' || el.fit === 'shrink';
|
||||
}
|
||||
|
||||
/** An outline, a hard shadow, a marker band, a curve or a fill other than the colour. */
|
||||
export function textEffectsInUse(el: BookTextElement): boolean {
|
||||
return !!(el.outline || el.shadow || el.highlight || el.curve || el.fillPaint);
|
||||
}
|
||||
|
||||
/** A shape's outline: drawn at a width, or set to dashes or dots. */
|
||||
export function shapeOutlineInUse(el: BookShapeElement): boolean {
|
||||
return (!!el.stroke && el.strokeWidth > 0) || el.strokeStyle !== 'solid';
|
||||
}
|
||||
|
||||
/** A cut edge (zigzag, scallop, torn and the rest) on a shape or a photo. */
|
||||
export function edgeInUse(edge: string | undefined): boolean {
|
||||
return (edge ?? 'straight') !== 'straight';
|
||||
}
|
||||
|
||||
/** A photo cut to a shape other than its own rectangle. */
|
||||
export function maskInUse(el: BookPhotoElement): boolean {
|
||||
return !!el.mask && el.mask !== 'rect';
|
||||
}
|
||||
|
||||
/** A photo's border, hard shadow or die-cut outline. */
|
||||
export function photoEdgesInUse(el: BookPhotoElement): boolean {
|
||||
const look = photoLookOf(el);
|
||||
return !!(look.border || look.shadow || look.cutout);
|
||||
}
|
||||
|
||||
/** Something in the middle of a drawn line: a sun, a heart, a dot. */
|
||||
export function ornamentInUse(el: BookLineElement): boolean {
|
||||
return el.ornament !== 'none';
|
||||
}
|
||||
|
||||
/** A map held at a zoom of its own rather than the one that fits its route. */
|
||||
export function mapZoomInUse(el: BookMapElement): boolean {
|
||||
return el.zoom != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A travel element set in another face, weight or size than it is placed in:
|
||||
* the sans at 700, at the drawn size (shared/src/book/book-travel.ts). The five
|
||||
* travel kinds share these three fields.
|
||||
*/
|
||||
export function travelTypeInUse(el: Pick<BookStatsElement, 'font' | 'weight' | 'textScale'>): boolean {
|
||||
return el.font !== 'sans' || el.weight !== 700 || el.textScale !== 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key the inspector mounts its groups under. A new selection mounts them
|
||||
* afresh, so every group opens or folds for the element now selected rather
|
||||
* than staying as it was left for the one before.
|
||||
*/
|
||||
export function selectionKey(ids: readonly string[]): string {
|
||||
return ids.join('|');
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { BookDocument, BookElement, JourneyStats } from '@trek/shared';
|
||||
import {
|
||||
bookIconElementSchema,
|
||||
bookLineElementSchema,
|
||||
bookMapElementSchema,
|
||||
bookPageSetupSchema,
|
||||
bookPhotoElementSchema,
|
||||
bookShapeElementSchema,
|
||||
bookStatsElementSchema,
|
||||
bookStickerElementSchema,
|
||||
bookTextElementSchema,
|
||||
} from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import { StudioInspector } from '../StudioInspector';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-INSPORDER-001 to FE-STUDIO-INSPORDER-011: the inspector's groups
|
||||
* per kind, in the one order every kind follows (what it is and says, where it
|
||||
* sits, how it looks, the effects, arrange); the folded groups opening on an
|
||||
* element that uses them and on nothing else; and every group mounted afresh
|
||||
* for the next selection.
|
||||
*/
|
||||
|
||||
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
|
||||
const FRAME = { x: 20, y: 30, w: 60, h: 40 };
|
||||
|
||||
/** The value, or a failed test saying it was missing. */
|
||||
function must<T>(value: T | null | undefined): T {
|
||||
if (value == null) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
|
||||
const text = (over: Record<string, unknown> = {}) =>
|
||||
bookTextElementSchema.parse({ id: 't1', kind: 'text', frame: FRAME, text: 'Harbour', ...over }) as BookElement;
|
||||
const photo = (over: Record<string, unknown> = {}) =>
|
||||
bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame: FRAME, photoId: 12, ...over }) as BookElement;
|
||||
const shape = (over: Record<string, unknown> = {}) =>
|
||||
bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame: FRAME, ...over }) as BookElement;
|
||||
const icon = () => bookIconElementSchema.parse({ id: 'i1', kind: 'icon', frame: FRAME, name: 'Plane' }) as BookElement;
|
||||
const sticker = () =>
|
||||
bookStickerElementSchema.parse({ id: 'k1', kind: 'sticker', frame: FRAME, stickerId: 'lemon' }) as BookElement;
|
||||
const line = () => bookLineElementSchema.parse({ id: 'l1', kind: 'line', frame: FRAME }) as BookElement;
|
||||
const map = () =>
|
||||
bookMapElementSchema.parse({
|
||||
id: 'm1',
|
||||
kind: 'map',
|
||||
frame: FRAME,
|
||||
countries: ['IS'],
|
||||
points: [{ lat: 64.14, lng: -21.94, label: 'Reykjavik' }],
|
||||
}) as BookElement;
|
||||
const stats = () => bookStatsElementSchema.parse({ id: 'st1', kind: 'stats', frame: FRAME }) as BookElement;
|
||||
|
||||
function load(elements: BookElement[], selection = elements.slice(0, 1).map((e) => e.id)) {
|
||||
useStudioStore.getState().load({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page,
|
||||
spreads: [{ id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null }],
|
||||
} as BookDocument);
|
||||
useStudioStore.getState().select(selection);
|
||||
}
|
||||
|
||||
function draw(live: JourneyStats | null = null) {
|
||||
return render(
|
||||
<StudioInspector
|
||||
spreadIndex={0}
|
||||
page={page}
|
||||
stats={live}
|
||||
path={[]}
|
||||
source={{ entries: [], photos: [], photoEntries: {} }}
|
||||
setPageNumbers={() => {}}
|
||||
t={(k: string) => k}
|
||||
locale="en"
|
||||
onOpenPanel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The groups the panel shows, top to bottom, by their headings. */
|
||||
const groups = (c: HTMLElement) =>
|
||||
[...c.querySelectorAll('.st-section.is-fold > h3 .st-section-label')].map((l) => l.textContent);
|
||||
|
||||
/** A group's head, the button that folds it. */
|
||||
const headOf = (label: string) =>
|
||||
must(
|
||||
[...document.querySelectorAll('.st-section-head')].find(
|
||||
(h) => h.querySelector('.st-section-label')?.textContent === label
|
||||
)
|
||||
);
|
||||
|
||||
/** Whether a group is open, read off its head. */
|
||||
function isOpen(label: string): boolean {
|
||||
return headOf(label).getAttribute('aria-expanded') === 'true';
|
||||
}
|
||||
|
||||
/** Opens a folded group. */
|
||||
function open(label: string) {
|
||||
if (!isOpen(label)) fireEvent.click(headOf(label));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useStudioStore.getState().load({ version: 1, title: 'T', page, spreads: [] } as unknown as BookDocument);
|
||||
});
|
||||
|
||||
describe('the order of the groups', () => {
|
||||
it('FE-STUDIO-INSPORDER-001: puts a text and a photo in the order every kind follows', () => {
|
||||
load([text()]);
|
||||
const t = draw();
|
||||
expect(groups(t.container)).toEqual([
|
||||
'journey.studio.text',
|
||||
'journey.studio.position',
|
||||
'journey.studio.typography',
|
||||
'journey.studio.colour',
|
||||
'journey.studio.fx.placement',
|
||||
'journey.studio.fx.effects',
|
||||
'journey.studio.arrange',
|
||||
]);
|
||||
t.unmount();
|
||||
|
||||
load([photo()]);
|
||||
expect(groups(draw().container)).toEqual([
|
||||
'journey.studio.crop',
|
||||
'journey.studio.position',
|
||||
'journey.studio.look',
|
||||
'journey.studio.mask',
|
||||
'journey.studio.edgeStyle',
|
||||
'journey.studio.photo.edges',
|
||||
'journey.studio.photo.adjust',
|
||||
'journey.studio.photo.duotone',
|
||||
'journey.studio.arrange',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPORDER-002: puts a shape, an icon, a sticker and a line in the same order', () => {
|
||||
const cases: [BookElement, string[]][] = [
|
||||
[
|
||||
shape(),
|
||||
[
|
||||
'journey.studio.shape',
|
||||
'journey.studio.position',
|
||||
'journey.studio.fill',
|
||||
'journey.studio.stroke',
|
||||
'journey.studio.edgeStyle',
|
||||
'journey.studio.arrange',
|
||||
],
|
||||
],
|
||||
[icon(), ['journey.studio.icon', 'journey.studio.position', 'journey.studio.look', 'journey.studio.arrange']],
|
||||
[sticker(), ['journey.studio.stickerColours', 'journey.studio.position', 'journey.studio.arrange']],
|
||||
[
|
||||
line(),
|
||||
[
|
||||
'journey.studio.kind.line',
|
||||
'journey.studio.position',
|
||||
'journey.studio.look',
|
||||
'journey.studio.lineEnds',
|
||||
'journey.studio.ornamentTitle',
|
||||
'journey.studio.arrange',
|
||||
],
|
||||
],
|
||||
];
|
||||
for (const [el, expected] of cases) {
|
||||
load([el]);
|
||||
const view = draw();
|
||||
expect(groups(view.container)).toEqual(expected);
|
||||
view.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPORDER-003: puts what a map and a summary say before their place, and their look after it', () => {
|
||||
load([map()]);
|
||||
const m = draw();
|
||||
expect(groups(m.container)).toEqual([
|
||||
'journey.studio.routeMap',
|
||||
'journey.studio.mapFraming',
|
||||
'journey.studio.position',
|
||||
'journey.studio.routeLook',
|
||||
'journey.studio.mapLayers',
|
||||
'journey.studio.typography',
|
||||
'journey.studio.colour',
|
||||
'journey.studio.arrange',
|
||||
]);
|
||||
m.unmount();
|
||||
|
||||
load([stats()]);
|
||||
expect(groups(draw().container)).toEqual([
|
||||
'journey.studio.metrics',
|
||||
'journey.studio.layout',
|
||||
'journey.studio.position',
|
||||
'journey.studio.typography',
|
||||
'journey.studio.colour',
|
||||
'journey.studio.arrange',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPORDER-004: gives several elements their angle without the frame, and arrange open', () => {
|
||||
load([shape(), shape({ id: 's2', frame: { x: 100, y: 30, w: 20, h: 20 } })], ['s1', 's2']);
|
||||
const { container } = draw();
|
||||
expect(groups(container)).toContain('journey.studio.position');
|
||||
expect(isOpen('journey.studio.position')).toBe(false);
|
||||
open('journey.studio.position');
|
||||
// One x for two elements would stack them, so the frame's numbers stay with a single selection.
|
||||
expect(screen.queryAllByRole('spinbutton', { name: (n) => n.startsWith('X') })).toHaveLength(0);
|
||||
expect(screen.getAllByRole('spinbutton', { name: (n) => n.startsWith('journey.studio.rotation') })).toHaveLength(1);
|
||||
expect(isOpen('journey.studio.arrange')).toBe(true);
|
||||
expect(screen.getByLabelText('journey.studio.showLayers')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the folded groups', () => {
|
||||
it('FE-STUDIO-INSPORDER-005: stay folded on a plain element and open on one that uses them', () => {
|
||||
load([shape()]);
|
||||
const plain = draw();
|
||||
expect(['journey.studio.position', 'journey.studio.stroke', 'journey.studio.edgeStyle'].map(isOpen)).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
plain.unmount();
|
||||
|
||||
load([shape({ rotation: 12, stroke: '#141414', strokeWidth: 0.5, edge: 'zigzag' })]);
|
||||
const used = draw();
|
||||
expect(['journey.studio.position', 'journey.studio.stroke', 'journey.studio.edgeStyle'].map(isOpen)).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
used.unmount();
|
||||
|
||||
load([text({ transform: 'upper' })]);
|
||||
const cased = draw();
|
||||
expect(isOpen('journey.studio.fx.placement')).toBe(true);
|
||||
expect(isOpen('journey.studio.fx.effects')).toBe(false);
|
||||
cased.unmount();
|
||||
|
||||
load([photo({ mask: 'heart', duotone: { dark: '#1a1a1a', light: '#f5e6c8' } })]);
|
||||
draw();
|
||||
expect(isOpen('journey.studio.mask')).toBe(true);
|
||||
expect(isOpen('journey.studio.photo.duotone')).toBe(true);
|
||||
expect(isOpen('journey.studio.photo.adjust')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPORDER-006: open the position of a sticker, which is turned as a matter of course', () => {
|
||||
load([sticker()]);
|
||||
draw();
|
||||
expect(isOpen('journey.studio.position')).toBe(true);
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.stickerTurnRight' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.stickerFlip' })).toBeTruthy();
|
||||
// One angle field and one opacity field, not a second pair in a group of the sticker's own.
|
||||
expect(screen.getAllByRole('spinbutton', { name: (n) => n.startsWith('journey.studio.rotation') })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPORDER-007: mount afresh for the next selection, remembering nothing', () => {
|
||||
load([shape(), shape({ id: 's2' })]);
|
||||
draw();
|
||||
open('journey.studio.position');
|
||||
expect(isOpen('journey.studio.position')).toBe(true);
|
||||
act(() => useStudioStore.getState().select(['s2']));
|
||||
expect(isOpen('journey.studio.position')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('several stickers at once', () => {
|
||||
it('FE-STUDIO-INSPORDER-009: mirror and turn every sticker selected, as the placement group did', () => {
|
||||
const second = bookStickerElementSchema.parse({
|
||||
id: 'k2',
|
||||
kind: 'sticker',
|
||||
frame: { x: 100, y: 30, w: 40, h: 40 },
|
||||
stickerId: 'lemon',
|
||||
}) as BookElement;
|
||||
load([sticker(), second], ['k1', 'k2']);
|
||||
draw();
|
||||
expect(isOpen('journey.studio.position')).toBe(true);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.stickerFlip' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.stickerTurnRight' }));
|
||||
const placed = must(useStudioStore.getState().doc).spreads[0]?.elements ?? [];
|
||||
expect(placed.map((e) => (e.kind === 'sticker' ? e.flipX : null))).toEqual([true, true]);
|
||||
expect(placed.map((e) => e.rotation)).toEqual([15, 15]);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-INSPORDER-010: offer no mirror when the selection is not all stickers', () => {
|
||||
load([sticker(), shape()], ['k1', 's1']);
|
||||
draw();
|
||||
expect(groups(document.body)).toContain('journey.studio.position');
|
||||
expect(screen.queryByRole('button', { name: 'journey.studio.stickerFlip' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.stickerTurnRight' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the arrange rows', () => {
|
||||
it('FE-STUDIO-INSPORDER-011: put each row under its label, in sets that move to a second line whole', () => {
|
||||
load([shape(), shape({ id: 's2', frame: { x: 100, y: 30, w: 20, h: 20 } })], ['s1', 's2']);
|
||||
const { container } = draw();
|
||||
const rows = [...container.querySelectorAll('.st-arrange > .st-line')];
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(rows.every((row) => row.classList.contains('is-wide'))).toBe(true);
|
||||
const sets = [...container.querySelectorAll('.st-arrange-row > .st-arrange-set')];
|
||||
// The six edges and the two spacings; the four steps of the stack, then grouping and the way to Layers.
|
||||
expect(sets.map((set) => set.querySelectorAll('button').length)).toEqual([6, 2, 4, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the labelled lines', () => {
|
||||
it('FE-STUDIO-INSPORDER-008: set every label apart from its control, so a long one wraps instead of being cut', () => {
|
||||
load([text({ binding: { source: 'entry.location', entryId: 7, format: 'dms' } })]);
|
||||
const { container } = draw();
|
||||
const lines = [...container.querySelectorAll('.st-line')];
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
for (const row of lines) {
|
||||
expect(row.querySelector(':scope > .st-line-label')).not.toBeNull();
|
||||
expect(row.querySelector(':scope > .st-line-body')).not.toBeNull();
|
||||
}
|
||||
for (const field of container.querySelectorAll('.st-num')) {
|
||||
expect(field.querySelector(':scope > .st-num-label')).not.toBeNull();
|
||||
expect(field.querySelector(':scope > .st-num-box')).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,11 @@ export type PagePresetId =
|
||||
*/
|
||||
| 'custom'
|
||||
|
||||
function preset(id: PagePresetId, w: number, h: number, labelKey: string): PagePreset {
|
||||
/**
|
||||
* A format from its page size. Its name is given as `labelKey` where the format
|
||||
* is listed, where the check of the client's translation keys can read it.
|
||||
*/
|
||||
function preset(id: PagePresetId, w: number, h: number, { labelKey }: { labelKey: string }): PagePreset {
|
||||
return {
|
||||
id,
|
||||
pageWidthMm: w,
|
||||
@@ -56,14 +60,14 @@ function preset(id: PagePresetId, w: number, h: number, labelKey: string): PageP
|
||||
}
|
||||
|
||||
export const PAGE_PRESETS: Record<PagePresetId, PagePreset> = {
|
||||
'square-210': preset('square-210', 210, 210, 'journey.studio.formatSquare21'),
|
||||
'square-300': preset('square-300', 300, 300, 'journey.studio.formatSquare30'),
|
||||
'a4-landscape': preset('a4-landscape', 297, 210, 'journey.studio.formatA4Landscape'),
|
||||
'a4-portrait': preset('a4-portrait', 210, 297, 'journey.studio.formatA4Portrait'),
|
||||
'a5-landscape': preset('a5-landscape', 210, 148, 'journey.studio.formatA5Landscape'),
|
||||
'square-210': preset('square-210', 210, 210, { labelKey: 'journey.studio.formatSquare21' }),
|
||||
'square-300': preset('square-300', 300, 300, { labelKey: 'journey.studio.formatSquare30' }),
|
||||
'a4-landscape': preset('a4-landscape', 297, 210, { labelKey: 'journey.studio.formatA4Landscape' }),
|
||||
'a4-portrait': preset('a4-portrait', 210, 297, { labelKey: 'journey.studio.formatA4Portrait' }),
|
||||
'a5-landscape': preset('a5-landscape', 210, 148, { labelKey: 'journey.studio.formatA5Landscape' }),
|
||||
// The dimensions here are only the starting point the fields open on; the
|
||||
// document keeps whatever the user types.
|
||||
custom: preset('custom', 210, 210, 'journey.studio.formatCustom'),
|
||||
custom: preset('custom', 210, 210, { labelKey: 'journey.studio.formatCustom' }),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,8 +86,10 @@ export function GradientStopBar({
|
||||
const onStopKey = (index: number) => (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const stop = paint.stops[index];
|
||||
if (!stop) return;
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && canRemove) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
// The stop's key even when the last two cannot go: it never reaches the page to delete the shape.
|
||||
e.preventDefault();
|
||||
if (!canRemove) return;
|
||||
onSelect(Math.max(0, index - 1));
|
||||
onChange(removeStop(paint, index), true);
|
||||
return;
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookPhotoElementSchema,
|
||||
spreadPaint,
|
||||
withSpreadPaint,
|
||||
withSpreadPaintByHand,
|
||||
type BookDocument,
|
||||
type BookElement,
|
||||
type BookPageSetup,
|
||||
@@ -77,20 +77,27 @@ export function withoutBackgroundPhoto(spread: BookSpread, page: BookPageSetup):
|
||||
const isCover = (spread: BookSpread) => spread.role === 'cover' || spread.role === 'back';
|
||||
|
||||
/**
|
||||
* Every page given the background of page `from`: its paint and colour,
|
||||
* nothing else. "Every page" is every page of its kind: an inside page
|
||||
* gives its background to the inside of the book, a cover to the covers,
|
||||
* since a cover is designed on its own and an inside page's paper is rarely
|
||||
* what the cover wants.
|
||||
* Every page given the background of page `from`: its paint and colour, and
|
||||
* whether it follows the theme, nothing else. "Every page" is every page of
|
||||
* its kind: an inside page gives its background to the inside of the book, a
|
||||
* cover to the covers, since a cover is designed on its own and an inside
|
||||
* page's paper is rarely what the cover wants. A page that follows the
|
||||
* theme's paper gives that link along; one painted by hand takes the others'
|
||||
* away, so the next theme applied leaves them all as they were made.
|
||||
*/
|
||||
export function backgroundToAll(doc: BookDocument, from: number): BookDocument {
|
||||
const source = doc.spreads[from];
|
||||
if (!source) return doc;
|
||||
const paint = spreadPaint(source);
|
||||
const kind = isCover(source);
|
||||
const link = source.backgroundToken;
|
||||
const given = (sp: BookSpread): BookSpread => {
|
||||
const painted = withSpreadPaintByHand(sp, paint);
|
||||
return link ? { ...painted, backgroundToken: link } : painted;
|
||||
};
|
||||
return {
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => (i === from || isCover(sp) !== kind ? sp : withSpreadPaint(sp, paint))),
|
||||
spreads: doc.spreads.map((sp, i) => (i === from || isCover(sp) !== kind ? sp : given(sp))),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { linearLine, r2 } from './paintGeometry';
|
||||
import { patternGeometry, type PatternBox } from './patterns';
|
||||
|
||||
/**
|
||||
* A paint as SVG parts over a box (paintRender.tsx explains why the ids come
|
||||
* from useId): kept apart from the components that draw them, so a shape and
|
||||
* the letters of a text share it without the component file exporting a helper.
|
||||
*/
|
||||
|
||||
/** A gradient's stops as SVG elements, in order. */
|
||||
function stopElements(stops: readonly BookPaintStop[]): ReactNode {
|
||||
// Two stops may share an offset (a hard edge), so the index keys them.
|
||||
return sortedStops(stops).map((s, i) => (
|
||||
<stop key={i} offset={r2(s.at)} stopColor={s.color} stopOpacity={stopAlpha(s) < 1 ? stopAlpha(s) : undefined} />
|
||||
));
|
||||
}
|
||||
|
||||
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
|
||||
export interface PaintParts {
|
||||
defs: ReactNode;
|
||||
fill: string;
|
||||
fillOpacity?: number;
|
||||
/** A pattern's geometry, to draw clipped to the outline over its fill. */
|
||||
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What drawing a paint over a box takes: the gradient definition and the fill
|
||||
* to point at it, or a pattern's geometry to draw clipped to the outline over
|
||||
* its ground. A shape's outline and the letters of a text both draw from it.
|
||||
*/
|
||||
export function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.color,
|
||||
fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
|
||||
pattern: null,
|
||||
};
|
||||
case 'linear': {
|
||||
const line = linearLine(paint.angle, box);
|
||||
return {
|
||||
defs: (
|
||||
<linearGradient id={id} gradientUnits="userSpaceOnUse" {...line}>
|
||||
{stopElements(paint.stops)}
|
||||
</linearGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
}
|
||||
case 'radial':
|
||||
return {
|
||||
defs: (
|
||||
<radialGradient
|
||||
id={id}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx={r2(box.x + paint.cx * box.w)}
|
||||
cy={r2(box.y + paint.cy * box.h)}
|
||||
r={r2((paint.r * Math.hypot(box.w, box.h)) / 2)}
|
||||
>
|
||||
{stopElements(paint.stops)}
|
||||
</radialGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
case 'pattern': {
|
||||
const geo = patternGeometry(paint, box);
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.bg ?? 'none',
|
||||
pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
|
||||
import { useMemo, type ReactNode, type SVGProps } from 'react';
|
||||
import { linearLine, r2 } from './paintGeometry';
|
||||
import { patternGeometry, type PatternBox } from './patterns';
|
||||
import type { BookPaint } from '@trek/shared';
|
||||
import { useMemo, type SVGProps } from 'react';
|
||||
import { r2 } from './paintGeometry';
|
||||
import { paintParts } from './paintParts';
|
||||
import type { PatternBox } from './patterns';
|
||||
import { usePaintId } from './usePaintId';
|
||||
|
||||
/**
|
||||
@@ -25,79 +26,6 @@ import { usePaintId } from './usePaintId';
|
||||
* element's own id is kept in front only to make the markup readable.
|
||||
*/
|
||||
|
||||
function Stops({ stops }: { stops: readonly BookPaintStop[] }) {
|
||||
return (
|
||||
<>
|
||||
{sortedStops(stops).map((s, i) => (
|
||||
<stop
|
||||
// Two stops may share an offset (a hard edge), so the index keys them.
|
||||
key={i}
|
||||
offset={r2(s.at)}
|
||||
stopColor={s.color}
|
||||
stopOpacity={stopAlpha(s) < 1 ? stopAlpha(s) : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
|
||||
interface PaintParts {
|
||||
defs: ReactNode;
|
||||
fill: string;
|
||||
fillOpacity?: number;
|
||||
/** A pattern's geometry, to draw clipped to the outline over its fill. */
|
||||
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
|
||||
}
|
||||
|
||||
function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.color,
|
||||
fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
|
||||
pattern: null,
|
||||
};
|
||||
case 'linear': {
|
||||
const line = linearLine(paint.angle, box);
|
||||
return {
|
||||
defs: (
|
||||
<linearGradient id={id} gradientUnits="userSpaceOnUse" {...line}>
|
||||
<Stops stops={paint.stops} />
|
||||
</linearGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
}
|
||||
case 'radial':
|
||||
return {
|
||||
defs: (
|
||||
<radialGradient
|
||||
id={id}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx={r2(box.x + paint.cx * box.w)}
|
||||
cy={r2(box.y + paint.cy * box.h)}
|
||||
r={r2((paint.r * Math.hypot(box.w, box.h)) / 2)}
|
||||
>
|
||||
<Stops stops={paint.stops} />
|
||||
</radialGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
case 'pattern': {
|
||||
const geo = patternGeometry(paint, box);
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.bg ?? 'none',
|
||||
pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OutlineProps = Omit<SVGProps<SVGPathElement>, 'fill' | 'd'>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,11 @@ interface PatternGeometry {
|
||||
const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
|
||||
const f = (v: number) => String(r2(v));
|
||||
|
||||
/** Threads per cell each way in linen: fine enough to read as cloth, few enough for a whole page under the cap. */
|
||||
const LINEN_THREADS = 6;
|
||||
/** Specks per cell in grain. */
|
||||
const GRAIN_SPECKS = 3;
|
||||
|
||||
/**
|
||||
* How many primitives a repeat needs over a region: `area` primitives per
|
||||
* cell squared, `lin` per cell along the width and the height. Read before
|
||||
@@ -66,6 +71,8 @@ const DENSITY: Record<BookPattern, { area: number; lin: number }> = {
|
||||
scales: { area: 2, lin: 0 },
|
||||
sunburst: { area: 0, lin: 0 },
|
||||
confetti: { area: 1.2, lin: 0 },
|
||||
linen: { area: 0, lin: 2 * LINEN_THREADS },
|
||||
grain: { area: GRAIN_SPECKS, lin: 0 },
|
||||
};
|
||||
|
||||
/** A primitive count estimate for a repeat over a region at a cell size. */
|
||||
@@ -269,6 +276,24 @@ const DRAW: Record<Exclude<BookPattern, 'sunburst'>, Draw> = {
|
||||
]);
|
||||
}
|
||||
},
|
||||
// Threads of uneven weight both ways, on two half-clear layers: where they cross the cloth reads denser.
|
||||
linen: (pen, g, s, alt) => {
|
||||
const rand = mulberry32(Math.round(s * 1000) + 31);
|
||||
const pitch = s / LINEN_THREADS;
|
||||
for (const y of steps(g.y0, g.y1, pitch)) pen.rect(g.x0, y, g.x1 - g.x0, pitch * (0.2 + rand() * 0.35));
|
||||
for (const x of steps(g.x0, g.x1, pitch)) alt.rect(x, g.y0, pitch * (0.2 + rand() * 0.35), g.y1 - g.y0);
|
||||
},
|
||||
// Specks of a few sizes, the larger ones fainter, scattered the same way on every render.
|
||||
grain: (pen, g, s, alt) => {
|
||||
const rand = mulberry32(Math.round(s * 1000) + 43);
|
||||
const n = Math.round((((g.x1 - g.x0) * (g.y1 - g.y0)) / (s * s)) * GRAIN_SPECKS);
|
||||
for (let i = 0; i < n && !pen.full && !alt.full; i++) {
|
||||
const x = g.x0 + rand() * (g.x1 - g.x0);
|
||||
const y = g.y0 + rand() * (g.y1 - g.y0);
|
||||
if (rand() < 0.6) pen.circle(x, y, s * (0.012 + rand() * 0.012));
|
||||
else alt.circle(x, y, s * (0.025 + rand() * 0.02));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** A seeded generator, so a confetti page draws the same scatter on every render and in print. */
|
||||
@@ -307,6 +332,13 @@ function sunburst(pen: Pen, box: PatternBox, cell: number, angle: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The repeats drawn on two layers, and how clear each layer is: textures stay quiet over their ground. */
|
||||
const LAYER_OPACITY: Partial<Record<BookPattern, readonly [number, number]>> = {
|
||||
gingham: [0.5, 0.5],
|
||||
linen: [0.32, 0.32],
|
||||
grain: [0.5, 0.28],
|
||||
};
|
||||
|
||||
/**
|
||||
* A repeat over a box. The geometry covers the box however it is turned: the
|
||||
* region drawn is the square around the box's circumscribed circle, and the
|
||||
@@ -338,17 +370,18 @@ export function patternGeometry(
|
||||
return { layers: [{ d: pen.d, opacity: 1 }], count: pen.count, cell };
|
||||
}
|
||||
|
||||
// The second layer only ever holds gingham's cross bands; the cell fitted above keeps both under the cap together.
|
||||
// The second layer holds gingham's and linen's cross bands and grain's larger specks;
|
||||
// the cell fitted above keeps both under the cap together.
|
||||
const alt = new Pen(cap);
|
||||
DRAW[paint.pattern](pen, region, cell, alt);
|
||||
const count = pen.count + alt.count;
|
||||
const layers =
|
||||
paint.pattern === 'gingham'
|
||||
? [
|
||||
{ d: pen.d, opacity: 0.5 },
|
||||
{ d: alt.d, opacity: 0.5 },
|
||||
]
|
||||
: [{ d: pen.d, opacity: 1 }];
|
||||
const two = LAYER_OPACITY[paint.pattern];
|
||||
const layers = two
|
||||
? [
|
||||
{ d: pen.d, opacity: two[0] },
|
||||
{ d: alt.d, opacity: two[1] },
|
||||
]
|
||||
: [{ d: pen.d, opacity: 1 }];
|
||||
return {
|
||||
layers: layers.filter((l) => l.d),
|
||||
count,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { FlipHorizontal2, Printer, RotateCcw, TriangleAlert } from 'lucide-react';
|
||||
|
||||
import '../../../styles/studio-photo.css';
|
||||
import { photoEdgesInUse } from '../inspector/inspectorModel';
|
||||
import { Line, NumField, Section, Switch } from '../StudioControls';
|
||||
import { Swatches } from '../StudioSwatches';
|
||||
import { StudioSlider } from './StudioSlider';
|
||||
@@ -264,7 +265,7 @@ export function PhotoLookSections(props: RowProps) {
|
||||
const raster = t('journey.studio.photo.rasterHint');
|
||||
return (
|
||||
<>
|
||||
<Section label={t('journey.studio.photo.edges')} defaultOpen={!!(look.border || look.shadow || look.cutout)}>
|
||||
<Section label={t('journey.studio.photo.edges')} defaultOpen={photoEdgesInUse(el)}>
|
||||
<EdgeRow
|
||||
label={t('journey.studio.photo.border')}
|
||||
value={look.border}
|
||||
|
||||
@@ -68,6 +68,14 @@ export function matchingEntries(source: JourneySource, query: string): JourneySo
|
||||
.some(v => v && v.toLowerCase().includes(q)))
|
||||
}
|
||||
|
||||
/** The four answers, each named as `emptyKey` so the check of the client's translation keys can read it. */
|
||||
const EMPTY = {
|
||||
none: { emptyKey: 'journey.studio.noPhotos' },
|
||||
unmatched: { emptyKey: 'journey.studio.noMatches' },
|
||||
entry: { emptyKey: 'journey.studio.noEntryPhotos' },
|
||||
loose: { emptyKey: 'journey.studio.noLoosePhotos' },
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Why the grid is empty, which is four different answers.
|
||||
*
|
||||
@@ -75,9 +83,9 @@ export function matchingEntries(source: JourneySource, query: string): JourneySo
|
||||
* on a day that has none, reads as the pictures having gone.
|
||||
*/
|
||||
export function emptyKeyFor(source: JourneySource, filter: PhotoFilter, admitted: Photo[]): string {
|
||||
if (!source.photos.length) return 'journey.studio.noPhotos'
|
||||
if (admitted.length) return 'journey.studio.noMatches'
|
||||
if (filter.kind === 'entry') return 'journey.studio.noEntryPhotos'
|
||||
if (filter.kind === 'loose') return 'journey.studio.noLoosePhotos'
|
||||
return 'journey.studio.noMatches'
|
||||
if (!source.photos.length) return EMPTY.none.emptyKey
|
||||
if (admitted.length) return EMPTY.unmatched.emptyKey
|
||||
if (filter.kind === 'entry') return EMPTY.entry.emptyKey
|
||||
if (filter.kind === 'loose') return EMPTY.loose.emptyKey
|
||||
return EMPTY.unmatched.emptyKey
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BookShapeId } from '@trek/shared'
|
||||
import { BOOK_FRAME_SHAPES, BOOK_SHAPE_GROUPS, type BookShapeId } from '@trek/shared'
|
||||
import { K, circle, polygon, r2, roundRect, scallop, star } from './shapePrimitives'
|
||||
import { MORE_SHAPE_PATHS } from './shapes-more'
|
||||
|
||||
@@ -323,91 +323,15 @@ export function unitPath(path: string): string {
|
||||
*/
|
||||
export const HOLED_SHAPES = new Set<BookShapeId>(['gear', 'postmark', 'label-tab'])
|
||||
|
||||
export interface ShapeGroup {
|
||||
/** i18n key under `journey.studio.shapeGroup`. */
|
||||
id: string
|
||||
shapes: BookShapeId[]
|
||||
/**
|
||||
* How the panel is divided and which shapes it offers as picture frames: the
|
||||
* one list in @trek/shared (book-shape-catalogue.ts), which the assistant's
|
||||
* asset listing reads too.
|
||||
*/
|
||||
export const SHAPE_GROUPS = BOOK_SHAPE_GROUPS
|
||||
export const FRAME_SHAPES = BOOK_FRAME_SHAPES
|
||||
|
||||
/** A shape's name in the reader's language, for its tooltip and a screen reader. */
|
||||
export function shapeName(shape: BookShapeId, t: (k: string) => string): string {
|
||||
return t(`journey.studio.shapeName.${shape}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* How the panel is divided.
|
||||
*
|
||||
* Grouped the way you go looking for them — "I want an arrow", "I want
|
||||
* something round and soft" — rather than by how they are built. Sixty
|
||||
* unlabelled tiles in one grid is a wall; six groups of ten is a menu.
|
||||
*/
|
||||
export const SHAPE_GROUPS: ShapeGroup[] = [
|
||||
{
|
||||
id: 'basic',
|
||||
shapes: ['rect', 'squircle', 'ellipse', 'capsule', 'triangle', 'triangle-down',
|
||||
'diamond', 'parallelogram', 'trapezoid', 'arch', 'half-circle', 'quarter-circle'],
|
||||
},
|
||||
{
|
||||
id: 'polygons',
|
||||
shapes: ['pentagon', 'hexagon', 'hexagon-flat', 'heptagon', 'octagon'],
|
||||
},
|
||||
{
|
||||
id: 'stars',
|
||||
shapes: ['star-4', 'star-5', 'star-6', 'star-8', 'star-12', 'burst', 'seal', 'sparkle',
|
||||
'star-rounded', 'star-twinkle'],
|
||||
},
|
||||
{
|
||||
id: 'arrows',
|
||||
shapes: ['arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'arrow-both',
|
||||
'chevron-right', 'chevron-left', 'arrow-bent', 'arrow-curve', 'arrow-circle', 'arrow-notched', 'arrow-thin'],
|
||||
},
|
||||
{
|
||||
id: 'speech',
|
||||
shapes: ['bubble-round', 'bubble-square', 'bubble-oval', 'bubble-think',
|
||||
'bubble-shout', 'bubble-side', 'bubble-capsule'],
|
||||
},
|
||||
{
|
||||
id: 'travel',
|
||||
shapes: ['pin', 'compass', 'mountain', 'sun', 'moon', 'cloud', 'cloud-puffy',
|
||||
'drop', 'wave', 'leaf', 'shield', 'ticket'],
|
||||
},
|
||||
{
|
||||
id: 'decor',
|
||||
shapes: ['heart', 'flower-5', 'flower-6', 'cross', 'plus', 'gear',
|
||||
'blob-1', 'blob-2', 'blob-3', 'blob-4', 'heart-round', 'heart-pair', 'flower-8'],
|
||||
},
|
||||
{
|
||||
id: 'banners',
|
||||
shapes: ['banner-ribbon', 'banner-pennant', 'banner-bookmark', 'banner-flag',
|
||||
'ribbon-ends', 'ribbon-arc', 'wavy-band', 'torn-strip'],
|
||||
},
|
||||
// The second shelf (shapes-more.ts).
|
||||
{ id: 'arches', shapes: ['arch-tall', 'arch-pointed', 'arch-double', 'arch-half', 'arch-rainbow'] },
|
||||
{
|
||||
id: 'cutouts',
|
||||
shapes: ['leaf-palm', 'leaf-oak', 'leaf-long', 'leaf-monstera', 'coral-1', 'coral-2', 'seaweed',
|
||||
'star-organic', 'sun-cutout', 'organic-1', 'organic-2', 'organic-3'],
|
||||
},
|
||||
{
|
||||
id: 'stamps',
|
||||
shapes: ['stamp-rect', 'stamp-circle', 'postmark', 'rosette', 'scallop-circle', 'badge-shield',
|
||||
'label-tab', 'ticket-stub', 'sunburst-12', 'sunburst-24'],
|
||||
},
|
||||
{ id: 'frames', shapes: ['frame-notched', 'frame-chamfer', 'frame-cushion'] },
|
||||
{ id: 'ornaments', shapes: ['flourish-scroll', 'laurel', 'fleuron', 'corner-flourish'] },
|
||||
]
|
||||
|
||||
/**
|
||||
* The shapes offered as picture frames.
|
||||
*
|
||||
* A subset, and the subset is the point: a photograph cut to a speech bubble's
|
||||
* tail or to a hairline is a photograph you cannot read. What is here holds an
|
||||
* image at book size — broad, mostly convex, no thin limbs.
|
||||
*/
|
||||
export const FRAME_SHAPES: BookShapeId[] = [
|
||||
'rect', 'squircle', 'ellipse', 'capsule', 'arch', 'half-circle',
|
||||
'triangle', 'diamond', 'pentagon', 'hexagon', 'octagon',
|
||||
'heart', 'star-5', 'star-6', 'seal', 'flower-5', 'flower-6',
|
||||
'blob-1', 'blob-2', 'blob-3', 'blob-4',
|
||||
'shield', 'drop', 'leaf', 'pin', 'banner-pennant', 'banner-bookmark',
|
||||
// The second shelf: arches, stamps and the frames cut for photographs.
|
||||
'arch-tall', 'arch-pointed', 'arch-double', 'arch-half', 'stamp-rect', 'stamp-circle',
|
||||
'scallop-circle', 'badge-shield', 'ticket-stub', 'heart-round', 'star-rounded', 'flower-8',
|
||||
'frame-notched', 'frame-chamfer', 'frame-cushion', 'organic-1', 'organic-2', 'organic-3',
|
||||
'star-organic', 'sun-cutout', 'bubble-side', 'bubble-capsule',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { BookPageSetup } from '@trek/shared';
|
||||
import { ImagePlus, Upload } from 'lucide-react';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import { photoSrc } from '../bookRender';
|
||||
import { emptyKeyFor, matchingEntries, matchingPhotos, photosFor, type PhotoFilter } from '../photoFilter';
|
||||
import { StudioContentFilter } from '../StudioContentFilter';
|
||||
import { PanelHead } from '../StudioPanelHead';
|
||||
import { StudioSearch } from '../StudioSearch';
|
||||
import type { JourneySource, StudioT } from '../StudioSidebar';
|
||||
import { StudioTabPanel, StudioTabs } from '../StudioTabs';
|
||||
import type { StudioUploader } from '../studioUpload';
|
||||
import { photoDrop, type DropPlace } from './contentDrops';
|
||||
import { StudioEntryCard } from './StudioEntryCard';
|
||||
import { useContentUpload } from './useContentUpload';
|
||||
|
||||
/**
|
||||
* The journey's own material.
|
||||
*
|
||||
* This is what separates a book maker from a drawing program: the pictures and
|
||||
* the words are already written, and the job is putting them on pages. Clicking
|
||||
* an item drops it on the current spread, centred, at a sensible size.
|
||||
*
|
||||
* Pictures can also come in here. A book is where you notice the one that
|
||||
* never made it into the journal, and going back to the journey to add it was
|
||||
* the one round trip Studio still asked for. What comes in goes through the
|
||||
* journey store like any other upload, so the journal sees it the moment the
|
||||
* panel does.
|
||||
*/
|
||||
|
||||
type Tab = 'photos' | 'text';
|
||||
|
||||
/**
|
||||
* The tooltip of an upload cell: its words in full, then what it does. The
|
||||
* cell is a square a third of the panel wide, so a long label ("Μεταφόρτωση
|
||||
* φωτογραφιών", "Uploading 12 of 30") is held to two lines there.
|
||||
*/
|
||||
const cellTitle = (label: string, hint: string) => `${label}\n${hint}`;
|
||||
|
||||
export function StudioContentPanel({
|
||||
source,
|
||||
page,
|
||||
t,
|
||||
locale,
|
||||
canEdit,
|
||||
onUpload,
|
||||
providers = [],
|
||||
onBrowseProvider,
|
||||
}: {
|
||||
source: JourneySource;
|
||||
page: BookPageSetup;
|
||||
t: StudioT;
|
||||
locale: string;
|
||||
canEdit: boolean;
|
||||
onUpload: StudioUploader;
|
||||
providers?: { id: string; name: string }[];
|
||||
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>('photos');
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<PhotoFilter>({ kind: 'all' });
|
||||
const tabsId = useId();
|
||||
|
||||
const entries = matchingEntries(source, query);
|
||||
const admitted = photosFor(source, filter);
|
||||
const photos = matchingPhotos(admitted, source, query);
|
||||
const emptyKey = emptyKeyFor(source, filter, admitted);
|
||||
|
||||
const addElement = useStudioStore((s) => s.addElement);
|
||||
const active = useStudioStore((s) => s.activeSpread);
|
||||
const spread = useStudioStore((s) => s.doc?.spreads[s.activeSpread]);
|
||||
const place: DropPlace = { page, single: !!spread && spread.role !== 'inner' };
|
||||
|
||||
const content = useContentUpload({
|
||||
filter,
|
||||
showArrived: (photoIds) => {
|
||||
setFilter({ kind: 'recent', photoIds });
|
||||
setQuery('');
|
||||
},
|
||||
onUpload,
|
||||
onBrowseProvider,
|
||||
enabled: canEdit && tab === 'photos',
|
||||
t,
|
||||
});
|
||||
const { upload, fileInput } = content;
|
||||
const uploadLabel = upload
|
||||
? t('journey.studio.uploading', { done: upload.done, total: upload.total })
|
||||
: t('journey.studio.uploadPhotos');
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.content')} />
|
||||
|
||||
<StudioSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
label={t('journey.studio.searchContent')}
|
||||
clearLabel={t('common.clear')}
|
||||
/>
|
||||
|
||||
<StudioTabs<Tab>
|
||||
idBase={tabsId}
|
||||
label={t('journey.studio.content')}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
{ id: 'photos', label: t('journey.studio.photos'), count: photos.length },
|
||||
{ id: 'text', label: t('journey.studio.entries'), count: entries.length },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Which entry, as a row of its own under the tabs rather than a control
|
||||
inside the search box: the search answers "which words" and this
|
||||
answers "which day", and the two combine.
|
||||
*/}
|
||||
{tab === 'photos' && (
|
||||
<StudioContentFilter
|
||||
source={source}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
admitted={admitted.length}
|
||||
pageEntryId={spread?.entryId ?? null}
|
||||
t={t}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
<StudioTabPanel
|
||||
idBase={tabsId}
|
||||
value={tab}
|
||||
className={`st-panel-scroll ${content.dropOver ? 'is-drop-over' : ''}`}
|
||||
{...content.dropZone}
|
||||
>
|
||||
{tab === 'photos' ? (
|
||||
<>
|
||||
{canEdit && (
|
||||
<p className="st-hint st-upload-where">
|
||||
{t(filter.kind === 'entry' ? 'journey.studio.uploadToEntry' : 'journey.studio.uploadToGallery')}
|
||||
</p>
|
||||
)}
|
||||
<div className="st-photo-grid">
|
||||
{/*
|
||||
The first cell rather than a button above the grid: it is the
|
||||
size and shape of what it produces, and it stays where the eye
|
||||
already is. Disabled, not hidden, while a batch is on its way, so
|
||||
the count has somewhere to be read.
|
||||
*/}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="st-photo-cell is-upload"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={!!upload}
|
||||
title={cellTitle(uploadLabel, t('journey.studio.uploadHint'))}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.6} aria-hidden />
|
||||
<span>{uploadLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
{canEdit &&
|
||||
onBrowseProvider &&
|
||||
providers.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p.id}
|
||||
className="st-photo-cell is-upload"
|
||||
onClick={() => {
|
||||
void content.pickFromProvider(p.id);
|
||||
}}
|
||||
disabled={!!upload}
|
||||
title={cellTitle(
|
||||
t('journey.studio.fromProvider', { name: p.name }),
|
||||
t('journey.studio.fromProviderHint', { name: p.name })
|
||||
)}
|
||||
>
|
||||
<ImagePlus size={18} strokeWidth={1.6} aria-hidden />
|
||||
<span>{t('journey.studio.fromProvider', { name: p.name })}</span>
|
||||
</button>
|
||||
))}
|
||||
{photos.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p.photoId}
|
||||
className="st-photo-cell"
|
||||
onClick={() => addElement(active, photoDrop(place, p.photoId))}
|
||||
title={t('journey.studio.addToPage')}
|
||||
aria-label={
|
||||
p.caption ? `${t('journey.studio.addToPage')}: ${p.caption}` : t('journey.studio.addToPage')
|
||||
}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// HTML5 drag is the right tool for one item from a list onto a target, and it gives
|
||||
// the thumbnail as the drag image for free; the canvas uses pointer events instead.
|
||||
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
|
||||
</button>
|
||||
))}
|
||||
{!photos.length && <p className="st-hint">{t(emptyKey)}</p>}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.heic,.heif"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
// Cleared straight away, as the spread import does, so the same pictures can be chosen twice.
|
||||
e.target.value = '';
|
||||
void content.send(files);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="st-entries">
|
||||
{entries.map((e) => (
|
||||
<StudioEntryCard
|
||||
key={e.id}
|
||||
entry={e}
|
||||
place={place}
|
||||
locale={locale}
|
||||
t={t}
|
||||
onAdd={(el) => addElement(active, el)}
|
||||
onPhotos={(entryId) => {
|
||||
setFilter({ kind: 'entry', id: entryId });
|
||||
setTab('photos');
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{!entries.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
|
||||
</div>
|
||||
)}
|
||||
</StudioTabPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { BookElement } from '@trek/shared';
|
||||
import { bookCoordValue, formatBookCoords, formatBookDate } from '@trek/shared';
|
||||
import { Camera } from 'lucide-react';
|
||||
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../../pages/journeyDetail/JourneyDetailPage.constants';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import type { JourneySource } from '../StudioSidebar';
|
||||
import { markDrop, prosConsDrop, textDrop, type DropPlace } from './contentDrops';
|
||||
|
||||
type Entry = JourneySource['entries'][number];
|
||||
|
||||
/** One chip of an entry: what it says, and the element it places, built when it is pressed. */
|
||||
interface EntryChip {
|
||||
key: string;
|
||||
label: string;
|
||||
make: () => BookElement;
|
||||
/** How many pieces it places, for the pros and cons. */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pieces of an entry that can go on a page, in the order the card offers
|
||||
* them: its words (title, story, place), its day and its point, set the way the
|
||||
* rest of the book sets them (the long date, coordinates in degrees), its mood
|
||||
* and weather, and its pros and cons. A piece the entry has not got is not
|
||||
* offered. Each element is built only when its chip is pressed, so a card
|
||||
* drawn twice mints no ids.
|
||||
*/
|
||||
function entryChips(entry: Entry, place: DropPlace, locale: string, t: (k: string) => string): EntryChip[] {
|
||||
const { id, title, story, location, date, lat, lng, mood, weather, pros, cons } = entry;
|
||||
// The short marks (a date, a point) land on a short line rather than across the whole column.
|
||||
const markWidth = place.page.pageWidth * 0.36;
|
||||
const bold = { size: 8, weight: 700 } as const;
|
||||
const chips: EntryChip[] = [];
|
||||
if (title)
|
||||
chips.push({
|
||||
key: 'title',
|
||||
label: t('journey.studio.addTitle'),
|
||||
make: () => textDrop(place, title, { size: 22, weight: 700 }, { entryId: id, source: 'entry.title' }),
|
||||
});
|
||||
if (story)
|
||||
chips.push({
|
||||
key: 'story',
|
||||
label: t('journey.studio.addStory'),
|
||||
make: () => textDrop(place, story, { size: 10, weight: 400 }, { entryId: id, source: 'entry.story' }),
|
||||
});
|
||||
if (location)
|
||||
chips.push({
|
||||
key: 'place',
|
||||
label: t('journey.studio.addPlace'),
|
||||
make: () => textDrop(place, location, bold, { entryId: id, source: 'entry.location' }),
|
||||
});
|
||||
if (date)
|
||||
chips.push({
|
||||
key: 'date',
|
||||
label: t('journey.studio.dateMark'),
|
||||
make: () =>
|
||||
textDrop(
|
||||
place,
|
||||
formatBookDate(date, locale),
|
||||
bold,
|
||||
{ entryId: id, source: 'entry.date', value: date },
|
||||
markWidth
|
||||
),
|
||||
});
|
||||
if (lat != null && lng != null)
|
||||
chips.push({
|
||||
key: 'coords',
|
||||
label: t('journey.studio.coordsMark'),
|
||||
make: () =>
|
||||
textDrop(
|
||||
place,
|
||||
formatBookCoords(lat, lng, 'dms'),
|
||||
bold,
|
||||
{ entryId: id, source: 'entry.location', format: 'dms', value: bookCoordValue(lat, lng) },
|
||||
markWidth
|
||||
),
|
||||
});
|
||||
const moodMark = mood ? MOOD_CONFIG[mood] : undefined;
|
||||
if (mood && moodMark) {
|
||||
const label = t(moodMark.label);
|
||||
chips.push({ key: 'mood', label, make: () => markDrop(place, 'mood', mood, label) });
|
||||
}
|
||||
const weatherMark = weather ? WEATHER_CONFIG[weather] : undefined;
|
||||
if (weather && weatherMark) {
|
||||
const label = t(weatherMark.label);
|
||||
chips.push({ key: 'weather', label, make: () => markDrop(place, 'weather', weather, label) });
|
||||
}
|
||||
if (pros.length > 0 || cons.length > 0)
|
||||
chips.push({
|
||||
key: 'proscons',
|
||||
label: t('journey.studio.addProsCons'),
|
||||
count: pros.length + cons.length,
|
||||
make: () => prosConsDrop(place, { pros, cons }, { pro: t('journey.editor.pros'), con: t('journey.editor.cons') }),
|
||||
});
|
||||
return chips;
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of the journal in Content > Entries: its name, its day and place,
|
||||
* and a chip for every piece of it that can go on a page.
|
||||
*
|
||||
* Each chip shows what it places rather than the field it comes from ("Sunny"
|
||||
* tells you what you are about to place; "Weather" does not), and every piece
|
||||
* arrives still bound to the entry. The last chip is not a drop: it opens the
|
||||
* entry's photos, because the day is how anyone thinks of its pictures.
|
||||
*/
|
||||
export function StudioEntryCard({
|
||||
entry,
|
||||
place,
|
||||
locale,
|
||||
t,
|
||||
onAdd,
|
||||
onPhotos,
|
||||
}: {
|
||||
entry: Entry;
|
||||
place: DropPlace;
|
||||
locale: string;
|
||||
t: (k: string) => string;
|
||||
onAdd: (el: BookElement) => void;
|
||||
/** Opens the photos tab on this entry's pictures. */
|
||||
onPhotos: (entryId: number) => void;
|
||||
}) {
|
||||
const { id, title, location, date, photoIds } = entry;
|
||||
return (
|
||||
<div className="st-entry">
|
||||
<div className="st-entry-head">{title || location || t('journey.studio.untitled')}</div>
|
||||
{(date || location) && (
|
||||
<div className="st-entry-meta">
|
||||
{/* Two facts, two badges. Joined by a dot they read as one string. The date follows the app's
|
||||
language, like every other date in TREK (utils/formatters.ts). */}
|
||||
{date && <span className="st-badge">{formatDate(date, locale) ?? date}</span>}
|
||||
{location && (
|
||||
<span className="st-badge is-quiet" title={location}>
|
||||
{location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="st-row st-entry-chips">
|
||||
{entryChips(entry, place, locale, t).map((chip) => (
|
||||
<button type="button" key={chip.key} className="st-chip" onClick={() => onAdd(chip.make())}>
|
||||
{chip.label}
|
||||
{chip.count != null && <span className="st-chip-count">{chip.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
{photoIds.length > 0 && (
|
||||
<button type="button" className="st-chip" onClick={() => onPhotos(id)}>
|
||||
<Camera size={12} aria-hidden />
|
||||
{t('journey.studio.entryPhotos')}
|
||||
<span className="st-chip-count">{photoIds.length}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from '../iconLibrary';
|
||||
import { Shelf } from '../StudioPanelHead';
|
||||
import { StudioSearch } from '../StudioSearch';
|
||||
|
||||
/**
|
||||
* The icon library, as the Icons tab of the Elements panel: a shelf of icons
|
||||
* for travel, then all fourteen hundred, and a search over their names.
|
||||
*
|
||||
* Fourteen hundred icons is fourteen hundred React components, each a
|
||||
* forwardRef around several SVG nodes, and rendering them all costs about a
|
||||
* second of blocked main thread on the first click of the tab, for a grid
|
||||
* whose first rows are all anybody sees. So it grows as it is scrolled, and a
|
||||
* search resets it: the results of a search are short, and starting them
|
||||
* part-drawn would be wrong.
|
||||
*/
|
||||
|
||||
/** How many icons the grid draws at a time. Two screens' worth at three across. */
|
||||
const ICON_PAGE = 120;
|
||||
|
||||
function IconCell({ name, onPick }: { name: string; onPick: (name: string) => void }) {
|
||||
const Icon = iconComponent(name);
|
||||
const label = iconLabel(name);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => onPick(name)}
|
||||
aria-label={name}
|
||||
title={label}
|
||||
>
|
||||
<Icon strokeWidth={1.7} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function StudioIconShelf({ onPick, t }: { onPick: (name: string) => void; t: (k: string) => string }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [limit, setLimit] = useState(ICON_PAGE);
|
||||
const tail = useRef<HTMLDivElement>(null);
|
||||
const matched = useMemo(() => searchIcons(query), [query]);
|
||||
const shown = useMemo(() => matched.slice(0, limit), [matched, limit]);
|
||||
|
||||
/*
|
||||
* Grow the grid when its foot comes into view. An observer rather than a
|
||||
* scroll handler: the panel is the scroller, the foot is the thing that
|
||||
* matters, and asking the browser to say when they meet costs nothing.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const foot = tail.current;
|
||||
if (!foot) return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) setLimit((n) => n + ICON_PAGE);
|
||||
});
|
||||
io.observe(foot);
|
||||
return () => io.disconnect();
|
||||
}, [shown.length, matched.length]);
|
||||
|
||||
const search = (value: string) => {
|
||||
setQuery(value);
|
||||
setLimit(ICON_PAGE);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StudioSearch
|
||||
value={query}
|
||||
onChange={search}
|
||||
label={t('journey.studio.searchIcons')}
|
||||
clearLabel={t('common.clear')}
|
||||
count={matched.length}
|
||||
/>
|
||||
<div className="st-panel-scroll">
|
||||
{/* The shelf, until somebody searches. */}
|
||||
{!query && (
|
||||
<Shelf label={t('journey.studio.iconsForTravel')}>
|
||||
<div className="st-shape-grid">
|
||||
{FEATURED_ICONS.map((name) => (
|
||||
<IconCell key={name} name={name} onPick={onPick} />
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
)}
|
||||
<Shelf label={query ? undefined : t('journey.studio.iconsAll')}>
|
||||
<div className="st-shape-grid">
|
||||
{shown.map((name) => (
|
||||
<IconCell key={name} name={name} onPick={onPick} />
|
||||
))}
|
||||
</div>
|
||||
{/* What the observer above watches for. */}
|
||||
{shown.length < matched.length && <div ref={tail} className="st-shelf-tail" />}
|
||||
{!matched.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
|
||||
</Shelf>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { BookPageSetup } from '@trek/shared';
|
||||
import { ChevronDown, ChevronUp, Copy, FileDown, Plus, Trash2 } from 'lucide-react';
|
||||
import { useRef, useState, type ChangeEvent } from 'react';
|
||||
|
||||
import { useElementSize } from '../../../hooks/useElementSize';
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import { folioLabel, foliosOf } from '../bookSheets';
|
||||
import { MAX_SPREAD_FILE_BYTES, importSpread } from '../spreadFile';
|
||||
import { SpreadThumb } from '../SpreadThumb';
|
||||
import { PanelHead } from '../StudioPanelHead';
|
||||
|
||||
/**
|
||||
* The Pages section of the rail: the book's pages in order, the actions on
|
||||
* each, and the two ways to a new one.
|
||||
*
|
||||
* Page thumbnails are drawn at a `scale()` rather than laid out by CSS (the
|
||||
* sheet inside them is sized in millimetres), so they need an actual pixel
|
||||
* width, and it is **measured** rather than derived. Deriving it was tried
|
||||
* twice and was wrong twice: the scrollbar appears only once the list is long
|
||||
* enough and takes a width that depends on the platform, and the arithmetic
|
||||
* that ignored it produced a card overhanging its own selection outline.
|
||||
* Measuring the row the cards sit in gets every contribution at once.
|
||||
*/
|
||||
|
||||
/** The narrowest a page preview is drawn. */
|
||||
const MIN_PREVIEW_W = 120;
|
||||
|
||||
/** The button's border and padding, which the row's width still includes. */
|
||||
const THUMB_CHROME = (2 + 3) * 2;
|
||||
|
||||
export function StudioPagesPanel({
|
||||
page,
|
||||
pxPerMm,
|
||||
bookView,
|
||||
t,
|
||||
}: {
|
||||
page: BookPageSetup;
|
||||
pxPerMm: number;
|
||||
bookView: boolean;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const doc = useStudioStore((s) => s.doc);
|
||||
const active = useStudioStore((s) => s.activeSpread);
|
||||
// The page a chat card points at, outlined here (journey-studio.md 4.2).
|
||||
const hinted = useStudioStore((s) => s.hintSpread);
|
||||
const setActive = useStudioStore((s) => s.setActiveSpread);
|
||||
const addSpread = useStudioStore((s) => s.addSpread);
|
||||
const insertSpread = useStudioStore((s) => s.insertSpread);
|
||||
const duplicateSpread = useStudioStore((s) => s.duplicateSpread);
|
||||
const removeSpread = useStudioStore((s) => s.removeSpread);
|
||||
const moveSpread = useStudioStore((s) => s.moveSpread);
|
||||
const canEditSpread = useStudioStore((s) => s.canEditSpread);
|
||||
const spreads = doc?.spreads ?? [];
|
||||
const list = useElementSize<HTMLDivElement>();
|
||||
const thumbWidth = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME);
|
||||
|
||||
/** Where a new spread would land: after the last inner one. */
|
||||
const lastInner = spreads.reduce((last, sp, i) => (sp.role === 'inner' ? i : last), -1);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
|
||||
const importFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
// Cleared straight away so choosing the same file twice in a row still fires a change event the second time.
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setImportError(null);
|
||||
// Checked before reading rather than after: the point of a limit on a file someone else wrote is not to parse it.
|
||||
if (file.size > MAX_SPREAD_FILE_BYTES) {
|
||||
setImportError(t('journey.studio.importSpreadFailed'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const spread = importSpread(JSON.parse(await file.text()), page);
|
||||
if (!spread) {
|
||||
setImportError(t('journey.studio.importSpreadFailed'));
|
||||
return;
|
||||
}
|
||||
insertSpread(lastInner, spread);
|
||||
} catch {
|
||||
setImportError(t('journey.studio.importSpreadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.pages')} count={spreads.length} />
|
||||
<div className="st-panel-scroll">
|
||||
<div className="st-thumbs" ref={list.ref}>
|
||||
{spreads.map((sp, i) => {
|
||||
const editable = canEditSpread(i);
|
||||
const folios = foliosOf(spreads, i, page.pageNumbers.startAt);
|
||||
return (
|
||||
<div className="st-thumb-row" key={sp.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`st-thumb ${i === active ? 'is-active' : ''} ${sp.id === hinted ? 'is-hinted' : ''}`}
|
||||
aria-current={i === active ? 'page' : undefined}
|
||||
onClick={() => setActive(i)}
|
||||
>
|
||||
<SpreadThumb
|
||||
spread={sp}
|
||||
page={page}
|
||||
width={thumbWidth}
|
||||
pxPerMm={pxPerMm}
|
||||
folios={folios}
|
||||
fold={bookView}
|
||||
className="st-thumb-sheet"
|
||||
coverInside={t(
|
||||
sp.role === 'last' ? 'journey.studio.backCoverInside' : 'journey.studio.coverInside'
|
||||
)}
|
||||
/>
|
||||
<span className="st-thumb-label">
|
||||
{sp.role === 'cover'
|
||||
? t('journey.studio.cover')
|
||||
: sp.role === 'back'
|
||||
? t('journey.studio.backCover')
|
||||
: folioLabel(folios)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/*
|
||||
The actions sit on the thumbnail rather than in a menu bar of
|
||||
their own: what you are acting on is the page you are pointing
|
||||
at. The cover and the back cover have none: a book has exactly
|
||||
one of each, and moving or deleting them is not a thing anyone
|
||||
means to do.
|
||||
*/}
|
||||
{editable && (
|
||||
<div className="st-thumb-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveSpread(i, -1)}
|
||||
disabled={!canEditSpread(i - 1)}
|
||||
title={t('journey.studio.movePageUp')}
|
||||
aria-label={t('journey.studio.movePageUp')}
|
||||
>
|
||||
<ChevronUp size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveSpread(i, 1)}
|
||||
disabled={!canEditSpread(i + 1)}
|
||||
title={t('journey.studio.movePageDown')}
|
||||
aria-label={t('journey.studio.movePageDown')}
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => duplicateSpread(i)}
|
||||
title={t('journey.studio.duplicatePage')}
|
||||
aria-label={t('journey.studio.duplicatePage')}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="is-danger"
|
||||
onClick={() => removeSpread(i)}
|
||||
title={t('journey.studio.deletePage')}
|
||||
aria-label={t('journey.studio.deletePage')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Insert between two spreads, where the new one will go: a single
|
||||
"add" at the end cannot express "another page here". */}
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className="st-thumb-insert"
|
||||
onClick={() => addSpread(i)}
|
||||
title={t('journey.studio.addPageAfter')}
|
||||
aria-label={t('journey.studio.addPageAfter')}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Two ways to get a new page: an empty one, or somebody else's design.
|
||||
Side by side because they answer the same question; the import is not
|
||||
a setting buried in a menu, it is the other half of "add".
|
||||
*/}
|
||||
<div className="st-add-row">
|
||||
<button
|
||||
type="button"
|
||||
className="st-add-page"
|
||||
onClick={() => addSpread(lastInner)}
|
||||
title={t('journey.studio.addPage')}
|
||||
aria-label={t('journey.studio.addPage')}
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
{t('journey.studio.addPageShort')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="st-add-page is-import"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
title={t('journey.studio.importSpreadHint')}
|
||||
>
|
||||
<FileDown size={14} aria-hidden />
|
||||
{t('journey.studio.importSpread')}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
void importFile(e);
|
||||
}}
|
||||
/>
|
||||
{importError && (
|
||||
<p className="st-add-error" role="alert">
|
||||
{importError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { BookDocument } from '@trek/shared';
|
||||
import { normalizeBookDocument } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import { StudioSidebar, type JourneySource } from '../StudioSidebar';
|
||||
import type { StudioUploader } from '../studioUpload';
|
||||
import { RAIL_SECTIONS, type RailSection } from './railSections';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-006: the rail down the side of Studio
|
||||
* and the panel it opens. Every section is a tab that opens its own panel
|
||||
* under one head, the keyboard reaches every tab, a long name wraps inside
|
||||
* the rail, and the shell can hold the open section so the inspector can open
|
||||
* one from the other side.
|
||||
*/
|
||||
|
||||
const SOURCE: JourneySource = { entries: [], photos: [], photoEntries: {} };
|
||||
const NO_UPLOAD: StudioUploader = async () => ({ photoIds: [], failed: 0, skippedVideos: 0 });
|
||||
|
||||
/** The value, or a failed test saying it was missing. */
|
||||
function must<T>(value: T | null | undefined): T {
|
||||
if (value == null) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
|
||||
const book = (): BookDocument =>
|
||||
normalizeBookDocument({
|
||||
version: 1,
|
||||
title: 'Lisbon',
|
||||
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||
spreads: [{ id: 'sp1', role: 'inner', entryId: null, background: null, elements: [], parked: [] }],
|
||||
});
|
||||
|
||||
function draw(over: { section?: RailSection; onSection?: (s: RailSection) => void } = {}) {
|
||||
const doc = must(useStudioStore.getState().doc);
|
||||
return render(
|
||||
<StudioSidebar
|
||||
page={doc.page}
|
||||
pxPerMm={96 / 25.4}
|
||||
bookView={false}
|
||||
source={SOURCE}
|
||||
stats={null}
|
||||
path={[]}
|
||||
t={(k: string) => k}
|
||||
locale="en"
|
||||
canEdit
|
||||
onUpload={NO_UPLOAD}
|
||||
onToggleStop={async () => true}
|
||||
section={over.section}
|
||||
onSection={over.onSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The head of the panel that is open: the name every panel opens on. */
|
||||
const panelTitle = () => must(document.querySelector('.st-side .st-panel-head .st-what-label')).textContent;
|
||||
|
||||
/** The name each section's panel opens on. */
|
||||
const TITLES: Record<RailSection, string> = {
|
||||
pages: 'journey.studio.pages',
|
||||
content: 'journey.studio.content',
|
||||
templates: 'journey.studio.templates',
|
||||
elements: 'journey.studio.elements',
|
||||
travel: 'journey.studio.travel',
|
||||
theme: 'studio.theme.panel',
|
||||
layers: 'journey.studio.layers',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useStudioStore.getState().load(book());
|
||||
});
|
||||
|
||||
describe('the rail', () => {
|
||||
it('FE-STUDIO-RAIL-001: lists the sections in the order a book is made, the theme and layers set apart', () => {
|
||||
const { container } = draw();
|
||||
const rail = screen.getByRole('tablist', { name: 'journey.studio.sections' });
|
||||
expect(rail.getAttribute('aria-orientation')).toBe('vertical');
|
||||
const tabs = within(rail).getAllByRole('tab');
|
||||
expect(tabs.map((t) => t.getAttribute('aria-label'))).toEqual(RAIL_SECTIONS.map((s) => s.labelKey));
|
||||
expect(RAIL_SECTIONS.map((s) => s.id)).toEqual([
|
||||
'pages',
|
||||
'content',
|
||||
'templates',
|
||||
'elements',
|
||||
'travel',
|
||||
'theme',
|
||||
'layers',
|
||||
]);
|
||||
// The rule sits right before the theme, and stickers are no longer a section of their own.
|
||||
const sep = must(container.querySelector('.st-rail-sep'));
|
||||
expect(sep.nextElementSibling?.getAttribute('aria-label')).toBe('studio.theme.panel');
|
||||
expect(tabs.some((t) => t.getAttribute('aria-label') === 'journey.studio.stickers')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-RAIL-002: opens on Pages, and every tab opens its own panel under its own head', () => {
|
||||
draw();
|
||||
expect(screen.getByRole('tab', { name: 'journey.studio.pages' }).getAttribute('aria-selected')).toBe('true');
|
||||
expect(panelTitle()).toBe(TITLES.pages);
|
||||
for (const entry of RAIL_SECTIONS) {
|
||||
const tab = screen.getByRole('tab', { name: entry.labelKey });
|
||||
fireEvent.click(tab);
|
||||
expect(tab.getAttribute('aria-selected')).toBe('true');
|
||||
const panel = must(document.querySelector('.st-side'));
|
||||
expect(panel.getAttribute('role')).toBe('tabpanel');
|
||||
expect(panel.getAttribute('aria-labelledby')).toBe(tab.id);
|
||||
expect(panelTitle()).toBe(TITLES[entry.id]);
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-STUDIO-RAIL-003: keeps one tab in the Tab order and reaches every tab with the arrows', () => {
|
||||
draw();
|
||||
const tabs = screen.getAllByRole('tab').filter((t) => t.classList.contains('st-rail-btn'));
|
||||
expect(tabs.map((t) => t.tabIndex)).toEqual([0, -1, -1, -1, -1, -1, -1]);
|
||||
const first = must(tabs[0]);
|
||||
first.focus();
|
||||
const seen = [document.activeElement];
|
||||
for (let i = 1; i < tabs.length; i++) {
|
||||
fireEvent.keyDown(must(document.activeElement), { key: 'ArrowDown' });
|
||||
seen.push(document.activeElement);
|
||||
}
|
||||
expect(seen).toEqual(tabs);
|
||||
// Down from the last wraps to the first; Home and End jump; arrowing opens nothing.
|
||||
fireEvent.keyDown(must(document.activeElement), { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(first);
|
||||
fireEvent.keyDown(first, { key: 'End' });
|
||||
expect(document.activeElement).toBe(tabs[tabs.length - 1]);
|
||||
fireEvent.keyDown(must(document.activeElement), { key: 'Home' });
|
||||
expect(document.activeElement).toBe(first);
|
||||
fireEvent.keyDown(first, { key: 'ArrowUp' });
|
||||
expect(document.activeElement).toBe(tabs[tabs.length - 1]);
|
||||
expect(panelTitle()).toBe(TITLES.pages);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-RAIL-004: carries each name whole in its title, in a label that may wrap', () => {
|
||||
const { container } = draw();
|
||||
for (const tab of screen.getAllByRole('tab').filter((t) => t.classList.contains('st-rail-btn'))) {
|
||||
const label = must(tab.querySelector('.st-rail-label'));
|
||||
expect(tab.getAttribute('title')).toBe(label.textContent);
|
||||
}
|
||||
expect(container.querySelectorAll('.st-rail-label')).toHaveLength(RAIL_SECTIONS.length);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-RAIL-005: opens the section the shell holds, and tells the shell which one was picked', () => {
|
||||
const onSection = vi.fn();
|
||||
const view = draw({ section: 'theme', onSection });
|
||||
expect(panelTitle()).toBe(TITLES.theme);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.layers' }));
|
||||
expect(onSection).toHaveBeenCalledWith('layers');
|
||||
view.unmount();
|
||||
draw({ section: 'layers', onSection });
|
||||
expect(panelTitle()).toBe(TITLES.layers);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-RAIL-006: finds the stickers under Elements, beside the icons', () => {
|
||||
draw();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.elements' }));
|
||||
const kinds = screen.getAllByRole('tab').filter((t) => t.classList.contains('st-subtab'));
|
||||
expect(kinds.map((t) => t.textContent)).toEqual([
|
||||
'journey.studio.text',
|
||||
'journey.studio.shapes',
|
||||
'journey.studio.lines',
|
||||
'journey.studio.frames',
|
||||
'journey.studio.icons',
|
||||
'journey.studio.stickers',
|
||||
]);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.stickers' }));
|
||||
expect(screen.getByRole('textbox', { name: 'journey.studio.searchStickers' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Fragment, useRef, type KeyboardEvent } from 'react';
|
||||
|
||||
import '../../../styles/studio-panels.css';
|
||||
import { rovingTarget } from '../studioRoving';
|
||||
import { RAIL_IDS, RAIL_SECTIONS, type RailSection } from './railSections';
|
||||
|
||||
/**
|
||||
* The rail down the side of Studio: one tab per section, each an icon over its
|
||||
* name.
|
||||
*
|
||||
* A list of tabs to a screen reader and to the keyboard, as it is to the eye:
|
||||
* Tab reaches the open section, the arrows move up and down the rail, Home and
|
||||
* End jump to its ends, and Enter or Space opens a section (studioRoving.ts).
|
||||
* A name longer than the rail is wide wraps onto a second line and breaks
|
||||
* inside a word only when the word alone is wider than the rail; the title
|
||||
* carries it whole either way.
|
||||
*/
|
||||
export function StudioRail({
|
||||
section,
|
||||
onSection,
|
||||
t,
|
||||
}: {
|
||||
section: RailSection;
|
||||
onSection: (id: RailSection) => void;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
const next = rovingTarget(e.key, index, RAIL_SECTIONS.length, { vertical: true, rtl: false });
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
tabs.current[next]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="st-rail">
|
||||
<div
|
||||
className="st-rail-tabs"
|
||||
role="tablist"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t('journey.studio.sections')}
|
||||
>
|
||||
{RAIL_SECTIONS.map((entry, i) => {
|
||||
const on = entry.id === section;
|
||||
const label = t(entry.labelKey);
|
||||
const Icon = entry.icon;
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
{entry.startsGroup && <span className="st-rail-sep" aria-hidden />}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={RAIL_IDS.tab(entry.id)}
|
||||
aria-selected={on}
|
||||
aria-controls={RAIL_IDS.panel}
|
||||
tabIndex={on ? 0 : -1}
|
||||
className={`st-rail-btn ${on ? 'is-on' : ''}`}
|
||||
ref={(el) => {
|
||||
tabs.current[i] = el;
|
||||
}}
|
||||
onClick={() => onSection(entry.id)}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.7} aria-hidden />
|
||||
<span className="st-rail-label" aria-hidden>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { bookPageSetupSchema, normalizeBookDocument } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { centred, markDrop, photoDrop, prosConsDrop, textDrop, type DropPlace } from './contentDrops';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-DROPS-001 to FE-STUDIO-DROPS-005: what the Content panel drops on
|
||||
* a page, centred on the open page, sized for what it is, and readable by the
|
||||
* document contract afterwards.
|
||||
*/
|
||||
|
||||
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
|
||||
const spread: DropPlace = { page, single: false };
|
||||
const cover: DropPlace = { page, single: true };
|
||||
|
||||
describe('the drops of the Content panel', () => {
|
||||
it('FE-STUDIO-DROPS-001: centres a frame across a spread, and on the one page of a cover', () => {
|
||||
expect(centred(spread, 100, 50)).toEqual({ x: 160, y: 80, w: 100, h: 50 });
|
||||
expect(centred(cover, 100, 50)).toEqual({ x: 55, y: 80, w: 100, h: 50 });
|
||||
});
|
||||
|
||||
it('FE-STUDIO-DROPS-002: drops a photo in a 4:3 frame a little over half the page', () => {
|
||||
const el = photoDrop(spread, 12);
|
||||
expect(el).toMatchObject({ kind: 'photo', photoId: 12, fit: 'cover', focalX: 0.5, focalY: 0.5 });
|
||||
expect(el.frame.w).toBeCloseTo(115.5, 5);
|
||||
expect(el.frame.h).toBeCloseTo(115.5 * 0.75, 5);
|
||||
expect(el.frame.x + el.frame.w / 2).toBeCloseTo(210, 5);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-DROPS-003: keeps a text bound to its entry, with the fact behind formatted words', () => {
|
||||
const title = textDrop(spread, 'Bergen', { size: 22, weight: 700 }, { entryId: 7, source: 'entry.title' });
|
||||
expect(title).toMatchObject({
|
||||
kind: 'text',
|
||||
text: 'Bergen',
|
||||
size: 22,
|
||||
weight: 700,
|
||||
leading: 1.1,
|
||||
tracking: -0.02,
|
||||
binding: { source: 'entry.title', entryId: 7 },
|
||||
overridden: false,
|
||||
});
|
||||
expect(title.frame.w).toBe(178);
|
||||
const point = textDrop(
|
||||
spread,
|
||||
'60° N',
|
||||
{ size: 8, weight: 700 },
|
||||
{ entryId: 7, source: 'entry.location', format: 'dms', value: '60.39,5.32' },
|
||||
75.6
|
||||
);
|
||||
expect(point).toMatchObject({
|
||||
binding: { source: 'entry.location', entryId: 7, format: 'dms', value: '60.39,5.32' },
|
||||
});
|
||||
expect(point.frame.w).toBe(75.6);
|
||||
const story = textDrop(spread, 'A long day', { size: 10, weight: 400 }, { entryId: 7, source: 'entry.story' });
|
||||
expect(story).toMatchObject({ leading: 1.55, tracking: 0, binding: { source: 'entry.story', entryId: 7 } });
|
||||
expect(story.kind === 'text' && story.binding).not.toHaveProperty('format');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-DROPS-004: drops a mood mark with its code and words, and both columns of pros and cons', () => {
|
||||
expect(markDrop(spread, 'mood', 'good', 'Good')).toMatchObject({
|
||||
kind: 'badge',
|
||||
variant: 'mood',
|
||||
code: 'good',
|
||||
text: 'Good',
|
||||
showIcon: true,
|
||||
showLabel: true,
|
||||
});
|
||||
const list = prosConsDrop(spread, { pros: ['Warm'], cons: ['Late', 'Loud'] }, { pro: 'Pros', con: 'Cons' });
|
||||
expect(list).toMatchObject({
|
||||
kind: 'list',
|
||||
layout: 'columns',
|
||||
proLabel: 'Pros',
|
||||
conLabel: 'Cons',
|
||||
items: [
|
||||
{ text: 'Warm', tone: 'pro' },
|
||||
{ text: 'Late', tone: 'con' },
|
||||
{ text: 'Loud', tone: 'con' },
|
||||
],
|
||||
});
|
||||
// Tall enough for the longer column, never past most of the page.
|
||||
expect(list.frame.h).toBeCloseTo(6 * (0.86 + 1.7 * 2), 5);
|
||||
const long = prosConsDrop(
|
||||
spread,
|
||||
{ pros: Array.from({ length: 40 }, (_, i) => `p${i}`), cons: [] },
|
||||
{
|
||||
pro: 'Pros',
|
||||
con: 'Cons',
|
||||
}
|
||||
);
|
||||
expect(long.frame.h).toBeCloseTo(210 * 0.6, 5);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-DROPS-005: drops what the document contract reads back whole', () => {
|
||||
const elements = [
|
||||
photoDrop(spread, 3),
|
||||
textDrop(spread, 'Bergen', { size: 22, weight: 700 }, { entryId: 7, source: 'entry.title' }),
|
||||
markDrop(spread, 'weather', 'sunny', 'Sunny'),
|
||||
prosConsDrop(spread, { pros: ['Warm'], cons: [] }, { pro: 'Pros', con: 'Cons' }),
|
||||
];
|
||||
const doc = normalizeBookDocument({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page,
|
||||
spreads: [{ id: 'sp1', role: 'inner', entryId: null, background: null, elements, parked: [] }],
|
||||
});
|
||||
expect(doc.spreads[0]?.elements.map((e) => e.kind)).toEqual(['photo', 'text', 'badge', 'list']);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user