mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 ────────────────────────────────────────────────────────────────────
|
// ── Studio ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const rail = (page: Page) => page.locator('.st-rail')
|
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 sidePanel = (page: Page) => page.locator('.st-panel.st-side')
|
||||||
const inspector = (page: Page) => page.locator('.st-inspector')
|
const inspector = (page: Page) => page.locator('.st-inspector')
|
||||||
const studioMenu = (page: Page) => page.locator('.st-menu').last()
|
const studioMenu = (page: Page) => page.locator('.st-menu').last()
|
||||||
@@ -1431,7 +1431,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
prepare: async p => {
|
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 expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
|
||||||
await settle(p)
|
await settle(p)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"src": {
|
"src": {
|
||||||
"@typescript-eslint/no-non-null-assertion": 401,
|
"@typescript-eslint/no-non-null-assertion": 385,
|
||||||
"@typescript-eslint/no-misused-promises": 850,
|
"@typescript-eslint/no-misused-promises": 849,
|
||||||
"no-console": 88,
|
"no-console": 88,
|
||||||
"complexity": 226,
|
"complexity": 222,
|
||||||
"@typescript-eslint/no-explicit-any": 146,
|
"@typescript-eslint/no-explicit-any": 146,
|
||||||
"trek/no-swallowed-catch": 130,
|
"trek/no-swallowed-catch": 130,
|
||||||
"max-params": 33,
|
"max-params": 32,
|
||||||
"trek/store-without-selector": 67,
|
"trek/store-without-selector": 67,
|
||||||
"react-hooks/refs": 556,
|
"react-hooks/refs": 556,
|
||||||
"react-hooks/set-state-in-effect": 194,
|
"react-hooks/set-state-in-effect": 194,
|
||||||
"@typescript-eslint/no-floating-promises": 165,
|
"@typescript-eslint/no-floating-promises": 165,
|
||||||
"react-hooks/exhaustive-deps": 116,
|
"react-hooks/exhaustive-deps": 116,
|
||||||
"@typescript-eslint/no-unused-vars": 125,
|
"@typescript-eslint/no-unused-vars": 124,
|
||||||
"react-hooks/immutability": 28,
|
"react-hooks/immutability": 28,
|
||||||
"max-lines-per-function": 132,
|
"max-lines-per-function": 130,
|
||||||
"react-hooks/static-components": 78,
|
"react-hooks/static-components": 78,
|
||||||
"react-hooks/preserve-manual-memoization": 12,
|
"react-hooks/preserve-manual-memoization": 12,
|
||||||
"no-useless-assignment": 7,
|
"no-useless-assignment": 7,
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
},
|
},
|
||||||
"tests": {
|
"tests": {
|
||||||
"@typescript-eslint/no-explicit-any": 490,
|
"@typescript-eslint/no-explicit-any": 490,
|
||||||
"@typescript-eslint/no-non-null-assertion": 1932,
|
"@typescript-eslint/no-non-null-assertion": 1931,
|
||||||
"@typescript-eslint/no-misused-promises": 22,
|
"@typescript-eslint/no-misused-promises": 22,
|
||||||
"@typescript-eslint/no-unused-vars": 51,
|
"@typescript-eslint/no-unused-vars": 51,
|
||||||
"@typescript-eslint/no-floating-promises": 33,
|
"@typescript-eslint/no-floating-promises": 33,
|
||||||
|
|||||||
@@ -117,11 +117,6 @@ export const DYNAMIC_ALLOWED = [
|
|||||||
template: 'settings.${mode}',
|
template: 'settings.${mode}',
|
||||||
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
|
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',
|
file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
|
||||||
template: 'reservations.${s}',
|
template: 'reservations.${s}',
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
||||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
||||||
"src/components/Studio/StudioShell.tsx": 1,
|
"src/components/Studio/StudioShell.tsx": 1,
|
||||||
"src/components/Studio/StudioSidebar.tsx": 1,
|
|
||||||
"src/components/Studio/TravelElements.tsx": 1,
|
"src/components/Studio/TravelElements.tsx": 1,
|
||||||
|
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
|
||||||
"src/components/Todo/useTodoList.ts": 1,
|
"src/components/Todo/useTodoList.ts": 1,
|
||||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||||
"src/components/shared/CountryFlag.tsx": 1,
|
"src/components/shared/CountryFlag.tsx": 1,
|
||||||
|
|||||||
@@ -56,9 +56,7 @@
|
|||||||
"components/Studio/StudioCanvas.tsx": 12,
|
"components/Studio/StudioCanvas.tsx": 12,
|
||||||
"components/Studio/StudioElementsPanel.tsx": 1,
|
"components/Studio/StudioElementsPanel.tsx": 1,
|
||||||
"components/Studio/StudioExport.tsx": 1,
|
"components/Studio/StudioExport.tsx": 1,
|
||||||
"components/Studio/StudioInspector.tsx": 1,
|
|
||||||
"components/Studio/StudioSavedLayouts.tsx": 1,
|
"components/Studio/StudioSavedLayouts.tsx": 1,
|
||||||
"components/Studio/StudioSidebar.tsx": 2,
|
|
||||||
"components/Studio/TravelElements.tsx": 4,
|
"components/Studio/TravelElements.tsx": 4,
|
||||||
"components/Studio/TravelPreview.tsx": 1,
|
"components/Studio/TravelPreview.tsx": 1,
|
||||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
|
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"components/Studio/LineHandles.tsx": 1,
|
"components/Studio/LineHandles.tsx": 1,
|
||||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||||
|
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||||
"mobile/components/trek3d/MascotClip.tsx": 1,
|
"mobile/components/trek3d/MascotClip.tsx": 1,
|
||||||
"pages/tripPlanner/PlanMapStage.tsx": 4
|
"pages/tripPlanner/PlanMapStage.tsx": 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,5 +26,5 @@
|
|||||||
"src/pages/atlas/useAtlas.ts": 1211,
|
"src/pages/atlas/useAtlas.ts": 1211,
|
||||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||||
"src/styles/studio.css": 2057
|
"src/styles/studio.css": 2009
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,6 @@
|
|||||||
"src/components/Settings/NotificationsTab.test.tsx": 1,
|
"src/components/Settings/NotificationsTab.test.tsx": 1,
|
||||||
"src/components/Settings/PasskeysSection.tsx": 1,
|
"src/components/Settings/PasskeysSection.tsx": 1,
|
||||||
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
|
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
|
||||||
"src/components/Studio/StudioInspector.tsx": 1,
|
|
||||||
"src/components/Todo/todoListModel.ts": 1,
|
"src/components/Todo/todoListModel.ts": 1,
|
||||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||||
"src/components/Tours/planner/useTourPlanner.ts": 2,
|
"src/components/Tours/planner/useTourPlanner.ts": 2,
|
||||||
|
|||||||
@@ -103,11 +103,10 @@
|
|||||||
"components/Studio/PhotoView.tsx": 2,
|
"components/Studio/PhotoView.tsx": 2,
|
||||||
"components/Studio/SpreadView.tsx": 1,
|
"components/Studio/SpreadView.tsx": 1,
|
||||||
"components/Studio/StudioElementsPanel.tsx": 8,
|
"components/Studio/StudioElementsPanel.tsx": 8,
|
||||||
"components/Studio/StudioInspector.tsx": 1,
|
|
||||||
"components/Studio/StudioShell.tsx": 2,
|
"components/Studio/StudioShell.tsx": 2,
|
||||||
"components/Studio/StudioSidebar.tsx": 2,
|
|
||||||
"components/Studio/TravelElements.tsx": 5,
|
"components/Studio/TravelElements.tsx": 5,
|
||||||
"components/Studio/printSheets.ts": 5,
|
"components/Studio/printSheets.ts": 5,
|
||||||
|
"components/Studio/sidebar/contentDrops.ts": 2,
|
||||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
|
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
|
||||||
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
|
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
|
||||||
"components/SystemNotices/SystemNoticeModal.tsx": 107,
|
"components/SystemNotices/SystemNoticeModal.tsx": 107,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} 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 { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
@@ -79,6 +79,7 @@ export default function AssistantHeader({
|
|||||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const menu = useContextMenu();
|
const menu = useContextMenu();
|
||||||
|
const moreRef = useRef<HTMLButtonElement>(null);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const threads = thread.threads ?? [];
|
const threads = thread.threads ?? [];
|
||||||
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
|
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
|
||||||
@@ -95,6 +96,14 @@ export default function AssistantHeader({
|
|||||||
run();
|
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) =>
|
const openMenu = (e: MouseEvent) =>
|
||||||
menu.open(
|
menu.open(
|
||||||
e,
|
e,
|
||||||
@@ -187,10 +196,12 @@ export default function AssistantHeader({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={t('assistant.window.menu')}>
|
<Tooltip label={t('assistant.window.menu')}>
|
||||||
<button
|
<button
|
||||||
|
ref={moreRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openMenu}
|
onClick={openMenu}
|
||||||
aria-label={t('assistant.window.menu')}
|
aria-label={t('assistant.window.menu')}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menu.menu !== null}
|
||||||
className={ICON_BUTTON}
|
className={ICON_BUTTON}
|
||||||
>
|
>
|
||||||
<Ellipsis size={14} aria-hidden="true" />
|
<Ellipsis size={14} aria-hidden="true" />
|
||||||
@@ -218,6 +229,7 @@ export default function AssistantHeader({
|
|||||||
icon={null}
|
icon={null}
|
||||||
label={th.title}
|
label={th.title}
|
||||||
active={th.id === thread.threadId}
|
active={th.id === thread.threadId}
|
||||||
|
current={th.id === thread.threadId}
|
||||||
onClick={() => pick(() => thread.openThread(th.id))}
|
onClick={() => pick(() => thread.openThread(th.id))}
|
||||||
trailing={
|
trailing={
|
||||||
<span className="flex-shrink-0 text-caption text-content-faint">
|
<span className="flex-shrink-0 text-caption text-content-faint">
|
||||||
@@ -234,7 +246,7 @@ export default function AssistantHeader({
|
|||||||
)}
|
)}
|
||||||
</PopoverListPanel>
|
</PopoverListPanel>
|
||||||
|
|
||||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
<ContextMenu menu={menu.menu} onClose={closeMenu} />
|
||||||
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={confirming}
|
isOpen={confirming}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ChevronRight, Luggage } from 'lucide-react';
|
import { ChevronRight, Luggage } from 'lucide-react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
import EmptyState from '../shared/EmptyState';
|
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
|
* 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
|
* 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 }) {
|
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const trips = useAssistantTripPicker();
|
const trips = useAssistantTripPicker();
|
||||||
|
const first = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
first.current?.focus({ preventScroll: true });
|
||||||
|
}, [trips]);
|
||||||
|
|
||||||
if (trips === null) {
|
if (trips === null) {
|
||||||
return (
|
return (
|
||||||
@@ -32,9 +39,10 @@ export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerT
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
|
<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">
|
<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}>
|
<li key={trip.id}>
|
||||||
<button
|
<button
|
||||||
|
ref={i === 0 ? first : undefined}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPick(trip)}
|
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"
|
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
|
* 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
|
* 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);
|
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', () => {
|
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 });
|
seed({ open: true, minimized: false });
|
||||||
const onFocusDay = vi.fn();
|
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
|
* 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
|
* 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
|
* the card carries it and the book is still at the trial's version), and
|
||||||
* where an applied change opens the Studio.
|
* 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 { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
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', () => {
|
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: '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: '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: '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: 'Mine' }, t)).toBe('Mine');
|
||||||
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1');
|
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');
|
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', () => {
|
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 doc = book();
|
||||||
const [edit] = pageViewOf(card(), doc, 'pending');
|
const [edit] = pageViewOf(card(), doc, 'pending');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type BookSpread,
|
type BookSpread,
|
||||||
findBookGrid,
|
findBookGrid,
|
||||||
findBookTemplate,
|
findBookTemplate,
|
||||||
|
findDecoratedTemplate,
|
||||||
setBookCover,
|
setBookCover,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
|
|
||||||
@@ -62,6 +63,10 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
|
|||||||
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
|
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
|
||||||
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
|
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
|
||||||
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
|
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. */
|
/** 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);
|
const grid = findBookGrid(layout.id);
|
||||||
return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null;
|
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;
|
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.
|
// 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' },
|
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
|
||||||
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
|
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' },
|
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
|
||||||
},
|
},
|
||||||
entities: {
|
entities: {
|
||||||
|
|||||||
@@ -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 { describe, it, expect, vi } from 'vitest'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||||
@@ -56,6 +56,15 @@ describe('PopoverItem', () => {
|
|||||||
expect(danger.style.background).toBe('none')
|
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', () => {
|
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(false)).toMatchObject({ opacity: 0.35, cursor: 'default' })
|
||||||
expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' })
|
expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' })
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface PopoverItemProps {
|
|||||||
label: string
|
label: string
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
active?: boolean
|
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
|
danger?: boolean
|
||||||
/** A secondary line, such as Add bag, in the faint text colour. */
|
/** A secondary line, such as Add bag, in the faint text colour. */
|
||||||
muted?: boolean
|
muted?: boolean
|
||||||
@@ -14,11 +16,12 @@ interface PopoverItemProps {
|
|||||||
trailing?: ReactNode
|
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)'
|
const tone = danger ? '#ef4444' : muted ? 'var(--text-faint)' : 'var(--text-primary)'
|
||||||
return (
|
return (
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
aria-current={current || undefined}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
|
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
|
||||||
padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer',
|
padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer',
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
AlignStartHorizontal,
|
AlignStartHorizontal,
|
||||||
AlignStartVertical,
|
AlignStartVertical,
|
||||||
AlignVerticalDistributeCenter,
|
AlignVerticalDistributeCenter,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
ChevronsDown,
|
||||||
|
ChevronsUp,
|
||||||
Group,
|
Group,
|
||||||
|
Layers,
|
||||||
Paintbrush,
|
Paintbrush,
|
||||||
Pipette,
|
Pipette,
|
||||||
Ungroup,
|
Ungroup,
|
||||||
@@ -18,17 +23,32 @@ import {
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useStudioStore } from '../../store/studioStore';
|
import { useStudioStore } from '../../store/studioStore';
|
||||||
|
import '../../styles/studio-panels.css';
|
||||||
import { Tooltip } from '../shared/Tooltip';
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
import { Choice, Section } from './StudioControls';
|
import { Choice, Line, Section } from './StudioControls';
|
||||||
import { useStudioShortcut } from './studioShortcut';
|
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
|
* 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
|
* 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 }[] = [
|
const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
|
||||||
@@ -40,6 +60,13 @@ const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
|
|||||||
{ edge: 'bottom', icon: AlignEndHorizontal, labelKey: 'journey.studio.layers.alignBottom' },
|
{ 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. */
|
/** How many things move as one in a selection: a group counts once. */
|
||||||
function unitCount(spread: BookSpread, ids: string[]): number {
|
function unitCount(spread: BookSpread, ids: string[]): number {
|
||||||
return distributableCount(
|
return distributableCount(
|
||||||
@@ -70,12 +97,22 @@ 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 spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
|
||||||
const selection = useStudioStore((s) => s.selection);
|
const selection = useStudioStore((s) => s.selection);
|
||||||
const hasStyle = useStudioStore((s) => s.styleClip !== null);
|
const hasStyle = useStudioStore((s) => s.styleClip !== null);
|
||||||
const align = useStudioStore((s) => s.align);
|
const align = useStudioStore((s) => s.align);
|
||||||
const distribute = useStudioStore((s) => s.distribute);
|
const distribute = useStudioStore((s) => s.distribute);
|
||||||
|
const raise = useStudioStore((s) => s.raise);
|
||||||
const group = useStudioStore((s) => s.group);
|
const group = useStudioStore((s) => s.group);
|
||||||
const ungroup = useStudioStore((s) => s.ungroup);
|
const ungroup = useStudioStore((s) => s.ungroup);
|
||||||
const copyStyle = useStudioStore((s) => s.copyStyle);
|
const copyStyle = useStudioStore((s) => s.copyStyle);
|
||||||
@@ -95,76 +132,102 @@ export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number;
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// Open for a multi-selection, where lining up is what comes next; folded for one element.
|
// 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}>
|
<Section key={units > 1 ? 'many' : 'one'} label={t('journey.studio.arrange')} defaultOpen={units > 1}>
|
||||||
<div className="st-row">
|
<div className="st-arrange">
|
||||||
{ALIGN.map((a) => (
|
<Line label={t('journey.studio.layers.align')} wide>
|
||||||
<IconButton
|
<div className="st-row st-arrange-row">
|
||||||
key={a.edge}
|
<span className="st-arrange-set">
|
||||||
label={t(a.labelKey)}
|
{ALIGN.map((a) => (
|
||||||
icon={a.icon}
|
<IconButton
|
||||||
disabled={!movable.length}
|
key={a.edge}
|
||||||
onClick={() => align(spreadIndex, a.edge, to)}
|
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}
|
||||||
/>
|
/>
|
||||||
))}
|
</Line>
|
||||||
<span className="st-row-sep" aria-hidden />
|
<Line label={t('journey.studio.order')} wide>
|
||||||
<IconButton
|
<div className="st-row st-arrange-row">
|
||||||
label={t('journey.studio.layers.distributeH')}
|
<span className="st-arrange-set">
|
||||||
icon={AlignHorizontalDistributeCenter}
|
{STACK.map((s) => (
|
||||||
disabled={units < 3}
|
<IconButton
|
||||||
onClick={() => distribute(spreadIndex, 'horizontal')}
|
key={s.to}
|
||||||
/>
|
label={t(s.labelKey)}
|
||||||
<IconButton
|
icon={s.icon}
|
||||||
label={t('journey.studio.layers.distributeV')}
|
onClick={() => raise(spreadIndex, selection, s.to)}
|
||||||
icon={AlignVerticalDistributeCenter}
|
/>
|
||||||
disabled={units < 3}
|
))}
|
||||||
onClick={() => distribute(spreadIndex, 'vertical')}
|
</span>
|
||||||
/>
|
<span className="st-arrange-set">
|
||||||
</div>
|
{(canGroup || !grouped) && (
|
||||||
<div className="st-arrange-to">
|
<IconButton
|
||||||
<span className="st-line-label">{t('journey.studio.layers.alignTo')}</span>
|
label={t('journey.studio.layers.groupAction')}
|
||||||
<Choice<BookAlignTo>
|
icon={Group}
|
||||||
value={to}
|
disabled={!canGroup}
|
||||||
ariaLabel={t('journey.studio.layers.alignTo')}
|
shortcut={keys('G')}
|
||||||
options={[
|
onClick={() => group(spreadIndex)}
|
||||||
{ 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') },
|
{grouped && (
|
||||||
]}
|
<IconButton
|
||||||
onPick={setChosenTo}
|
label={t('journey.studio.layers.ungroup')}
|
||||||
/>
|
icon={Ungroup}
|
||||||
</div>
|
shortcut={keys('⇧', 'G')}
|
||||||
<div className="st-row st-arrange-acts">
|
onClick={() => ungroup(spreadIndex)}
|
||||||
{(canGroup || !grouped) && (
|
/>
|
||||||
<IconButton
|
)}
|
||||||
label={t('journey.studio.layers.groupAction')}
|
{onShowLayers && (
|
||||||
icon={Group}
|
<IconButton label={t('journey.studio.showLayers')} icon={Layers} onClick={onShowLayers} />
|
||||||
disabled={!canGroup}
|
)}
|
||||||
shortcut={keys('G')}
|
</span>
|
||||||
onClick={() => group(spreadIndex)}
|
</div>
|
||||||
/>
|
</Line>
|
||||||
)}
|
<Line label={t('journey.studio.style')} wide>
|
||||||
{grouped && (
|
<div className="st-row">
|
||||||
<IconButton
|
<IconButton
|
||||||
label={t('journey.studio.layers.ungroup')}
|
label={t('journey.studio.layers.copyStyle')}
|
||||||
icon={Ungroup}
|
icon={Pipette}
|
||||||
shortcut={keys('⇧', 'G')}
|
shortcut={keys('alt', 'C')}
|
||||||
onClick={() => ungroup(spreadIndex)}
|
onClick={() => copyStyle(spreadIndex)}
|
||||||
/>
|
/>
|
||||||
)}
|
<IconButton
|
||||||
<span className="st-row-sep" aria-hidden />
|
label={t('journey.studio.layers.pasteStyle')}
|
||||||
<IconButton
|
icon={Paintbrush}
|
||||||
label={t('journey.studio.layers.copyStyle')}
|
disabled={!hasStyle || !movable.length}
|
||||||
icon={Pipette}
|
shortcut={keys('alt', 'V')}
|
||||||
shortcut={keys('alt', 'C')}
|
onClick={() => pasteStyle(spreadIndex)}
|
||||||
onClick={() => copyStyle(spreadIndex)}
|
/>
|
||||||
/>
|
</div>
|
||||||
<IconButton
|
</Line>
|
||||||
label={t('journey.studio.layers.pasteStyle')}
|
|
||||||
icon={Paintbrush}
|
|
||||||
disabled={!hasStyle || !movable.length}
|
|
||||||
shortcut={keys('alt', 'V')}
|
|
||||||
onClick={() => pasteStyle(spreadIndex)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{asGroup && <p className="st-hint st-arrange-hint">{t('journey.studio.layers.groupHint')}</p>}
|
{asGroup && <p className="st-hint st-arrange-hint">{t('journey.studio.layers.groupHint')}</p>}
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -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 { Check, CopyCheck, ImageOff } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useStudioStore } from '../../store/studioStore';
|
import { useStudioStore } from '../../store/studioStore';
|
||||||
@@ -37,9 +37,10 @@ export function StudioBackgroundSection({
|
|||||||
}) {
|
}) {
|
||||||
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
|
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
|
||||||
const commit = useStudioStore((s) => s.commit);
|
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) => ({
|
const channel = usePaintChannel((paint) => (doc) => ({
|
||||||
...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.
|
// "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 type { BookDocument, BookElement } from '@trek/shared';
|
||||||
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } 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 { fireEvent, render } from '../../../tests/helpers/render';
|
||||||
import { useStudioStore } from '../../store/studioStore';
|
import { useStudioStore } from '../../store/studioStore';
|
||||||
|
import { AngleDial } from './paint/AngleDial';
|
||||||
|
import { GradientStopBar } from './paint/GradientStopBar';
|
||||||
import { StudioCanvas } from './StudioCanvas';
|
import { StudioCanvas } from './StudioCanvas';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -267,6 +269,55 @@ describe('the keyboard', () => {
|
|||||||
expect(again.defaultPrevented).toBe(false);
|
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', () => {
|
it('leaves the keys to a field being typed in', () => {
|
||||||
load([shape('a', 10, 10)]);
|
load([shape('a', 10, 10)]);
|
||||||
store().select(['a']);
|
store().select(['a']);
|
||||||
|
|||||||
@@ -235,7 +235,6 @@ export function StudioCanvas({
|
|||||||
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
|
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
|
||||||
mask: null, frameStyle: 'none',
|
mask: null, frameStyle: 'none',
|
||||||
} as BookElement)
|
} 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
|
* heading over the grid rather than as one control among several, and a long
|
||||||
* entry name has somewhere to go.
|
* entry name has somewhere to go.
|
||||||
*
|
*
|
||||||
* The popover is the top bar's format menu, hung from the left edge instead of
|
* The popover is the top bar's format menu, hung from the start edge instead
|
||||||
* the right: the side panel is 236px wide and a menu anchored right would open
|
* of the end: the side panel is 236px wide and a menu anchored at the end would
|
||||||
* into the rail.
|
* open into the rail. A long entry name takes two lines in the chip, and the
|
||||||
|
* menu shows every name whole.
|
||||||
*/
|
*/
|
||||||
export function StudioContentFilter({
|
export function StudioContentFilter({
|
||||||
source, filter, setFilter, admitted, pageEntryId, t, locale,
|
source, filter, setFilter, admitted, pageEntryId, t, locale,
|
||||||
@@ -79,7 +80,7 @@ export function StudioContentFilter({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
title={t('journey.studio.filterPhotos')}
|
title={t('journey.studio.filterPhotos')}
|
||||||
>
|
>
|
||||||
<span className="st-filter-name">{label}</span>
|
<span className="st-filter-name" title={label}>{label}</span>
|
||||||
<em>{admitted}</em>
|
<em>{admitted}</em>
|
||||||
<ChevronDown size={12} style={{ opacity: .5 }} />
|
<ChevronDown size={12} style={{ opacity: .5 }} />
|
||||||
</button>
|
</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 { Check, ChevronDown } from 'lucide-react'
|
||||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||||
|
|
||||||
@@ -31,10 +31,15 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
|
|||||||
/**
|
/**
|
||||||
* One group of properties.
|
* One group of properties.
|
||||||
*
|
*
|
||||||
* `defaultOpen={false}` is for the groups you set once and rarely return to —
|
* `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
|
* the frame's numbers, the effects, the advanced settings of a kind. The
|
||||||
* remembered: coming back to an element and finding its panel folded up
|
* inspector opens such a group anyway when the element already uses it, so a
|
||||||
* differently from last time is worse than opening one group again.
|
* 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 }: {
|
export function Section({ label, children, defaultOpen = true, hint, badge }: {
|
||||||
label: string
|
label: string
|
||||||
@@ -51,15 +56,19 @@ export function Section({ label, children, defaultOpen = true, hint, badge }: {
|
|||||||
badge?: string
|
badge?: string
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(defaultOpen)
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
|
const body = useId()
|
||||||
return (
|
return (
|
||||||
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
|
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
|
||||||
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}>
|
<h3 className="st-section-title">
|
||||||
<span className="st-section-label">{label}</span>
|
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}
|
||||||
{badge && <span className="st-section-badge">{badge}</span>}
|
aria-controls={open ? body : undefined}>
|
||||||
<ChevronDown size={13} className="st-section-caret" />
|
<span className="st-section-label">{label}</span>
|
||||||
</button>
|
{badge && <span className="st-section-badge">{badge}</span>}
|
||||||
|
<ChevronDown size={13} className="st-section-caret" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="st-section-body">
|
<div className="st-section-body" id={body}>
|
||||||
{hint && <p className="st-hint st-section-hint">{hint}</p>}
|
{hint && <p className="st-hint st-section-hint">{hint}</p>}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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 }: {
|
export function Line({ label, children, wide = false }: {
|
||||||
label: string
|
label: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
bookShapeTakesEdge,
|
bookShapeTakesEdge,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
|
|
||||||
import { NumField, Section } from './StudioControls';
|
import { edgeInUse } from './inspector/inspectorModel';
|
||||||
import { DEFAULT_EDGE_SIZE } from './shapeOutline';
|
import { DEFAULT_EDGE_SIZE } from './shapeOutline';
|
||||||
import { edgedPath } from './shapes-edges';
|
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
|
* 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.
|
// 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;
|
if (!takes && current === 'straight') return null;
|
||||||
return (
|
return (
|
||||||
<Section label={t('journey.studio.edgeStyle')} defaultOpen={current !== 'straight'}>
|
<Section label={t('journey.studio.edgeStyle')} defaultOpen={edgeInUse(edge)}>
|
||||||
{!takes && (
|
{!takes && <p className="st-hint st-hint-before">{t('journey.studio.edgeUnavailable')}</p>}
|
||||||
<p className="st-hint" style={{ paddingBottom: 8 }}>
|
|
||||||
{t('journey.studio.edgeUnavailable')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="st-mini-shapes">
|
<div className="st-mini-shapes">
|
||||||
{BOOK_EDGES.map((e) => (
|
{BOOK_EDGES.map((e) => (
|
||||||
<button
|
<button
|
||||||
@@ -72,7 +69,7 @@ export function EdgeSection({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{takes && current !== 'straight' && (
|
{takes && current !== 'straight' && (
|
||||||
<div style={{ marginTop: 10 }}>
|
<div className="st-section-gap">
|
||||||
<NumField
|
<NumField
|
||||||
label={t('journey.studio.edgeSize')}
|
label={t('journey.studio.edgeSize')}
|
||||||
value={edgeSize ?? DEFAULT_EDGE_SIZE}
|
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 { useId, useState } from 'react'
|
||||||
import { Minus, Quote, Search, Square, X } from 'lucide-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 type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
|
||||||
import { useStudioStore } from '../../store/studioStore'
|
import { useStudioStore } from '../../store/studioStore'
|
||||||
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS } from './shapes'
|
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 {
|
||||||
|
BOOK_GRIDS as GRIDS, MAX_SPREAD_ELEMENTS, bookElementId, bookShapeSize, bookGridElements as gridElements,
|
||||||
|
defaultBookGridBox as defaultGridBox,
|
||||||
|
} from '@trek/shared'
|
||||||
import { shapeTile } from './shapeOutline'
|
import { shapeTile } from './shapeOutline'
|
||||||
import { StudioLineShelf } from './StudioLineShelf'
|
import { StudioLineShelf } from './StudioLineShelf'
|
||||||
import { PanelHead } from './StudioPanelHead'
|
import { PanelHead, Shelf } from './StudioPanelHead'
|
||||||
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
|
import { StudioTabPanel, StudioTabs } from './StudioTabs'
|
||||||
import '../../styles/studio-elements.css'
|
import { StudioStickersPanel } from './StudioStickersPanel'
|
||||||
|
import { StudioIconShelf } from './sidebar/StudioIconShelf'
|
||||||
|
import { FRAME_STYLE_OPTIONS, type FrameStyle } from './studioOptions'
|
||||||
import { StudioTextStyles } from './StudioTextStyles'
|
import { StudioTextStyles } from './StudioTextStyles'
|
||||||
import type { ShelfSource } from './textStylesModel'
|
import type { ShelfSource } from './textStylesModel'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything you can add that does not come from the journey: type, shapes,
|
* 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
|
* One panel with six tabs rather than six entries on the rail. They are all
|
||||||
* kind of answer to "what do I put on this page", and a rail long enough to
|
* the same kind of answer to "what do I put on this page", and a rail long
|
||||||
* hold them separately stops being faster to hit than a word would be.
|
* 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).
|
// The book's own ids, the ones the layouts and the assistant mint (shared/src/book/book-ids.ts).
|
||||||
const uid = bookElementId
|
const uid = bookElementId
|
||||||
|
|
||||||
/** How many icons the grid draws at a time. Two screens' worth at four across. */
|
type Tab = 'text' | 'shapes' | 'lines' | 'frames' | 'icons' | 'stickers'
|
||||||
const ICON_PAGE = 120
|
|
||||||
|
|
||||||
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.
|
* 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` }} />
|
<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. */}
|
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" /></>}
|
{style === 'tape' && <><em className="st-frame-glyph-tape" /><em className="st-frame-glyph-tape is-right" /></>}
|
||||||
</span>
|
</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. */
|
/** 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
|
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 }: {
|
* What every tab places, and where: centred on the page that is open, across
|
||||||
page: BookPageSetup
|
* both pages of a spread or on the one page of a cover.
|
||||||
source: ShelfSource
|
*/
|
||||||
t: (k: string) => string
|
function useElementAdders(page: BookPageSetup) {
|
||||||
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)
|
|
||||||
const addElement = useStudioStore(s => s.addElement)
|
const addElement = useStudioStore(s => s.addElement)
|
||||||
const commit = useStudioStore(s => s.commit)
|
const commit = useStudioStore(s => s.commit)
|
||||||
const select = useStudioStore(s => s.select)
|
const select = useStudioStore(s => s.select)
|
||||||
const active = useStudioStore(s => s.activeSpread)
|
const active = useStudioStore(s => s.activeSpread)
|
||||||
const doc = useStudioStore(s => s.doc)
|
const role = useStudioStore(s => s.doc?.spreads[s.activeSpread]?.role)
|
||||||
const spread = doc?.spreads[active]
|
const single = role !== undefined && role !== 'inner'
|
||||||
const single = !!spread && spread.role !== 'inner'
|
|
||||||
|
|
||||||
const centre = (w: number, h: number) => {
|
const centre = (w: number, h: number) => {
|
||||||
const W = single ? page.pageWidth : page.pageWidth * 2
|
const W = single ? page.pageWidth : page.pageWidth * 2
|
||||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
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, {
|
addElement(active, {
|
||||||
id: uid('t'), kind: 'text', frame: centre(page.pageWidth * 0.7, size * 0.5 + 8),
|
id: uid('t'), kind: 'text', frame: centre(page.pageWidth * 0.7, size * 0.5 + 8),
|
||||||
rotation: 0, opacity: 1, locked: false,
|
rotation: 0, opacity: 1, locked: false,
|
||||||
@@ -205,7 +150,7 @@ export function StudioElementsPanel({ page, t, source }: {
|
|||||||
: own ? centre(own.w, own.h)
|
: own ? centre(own.w, own.h)
|
||||||
: centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side),
|
: centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side),
|
||||||
rotation: 0, opacity: 1, locked: false,
|
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.
|
// so it moves, colours and snaps like everything else on the page.
|
||||||
shape: isRule ? 'rect' : shape,
|
shape: isRule ? 'rect' : shape,
|
||||||
fill: opts.outline ? null : '#141414',
|
fill: opts.outline ? null : '#141414',
|
||||||
@@ -218,11 +163,9 @@ export function StudioElementsPanel({ page, t, source }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An icon, placed as a square.
|
* 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
|
||||||
* Square because that is what it is: unlike a shape, an icon keeps its
|
* air on two sides.
|
||||||
* proportions inside whatever frame it is given, so a rectangle would only
|
|
||||||
* pad it with air on two sides.
|
|
||||||
*/
|
*/
|
||||||
const addIcon = (name: string) => {
|
const addIcon = (name: string) => {
|
||||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.18
|
const side = Math.min(page.pageWidth, page.pageHeight) * 0.18
|
||||||
@@ -247,240 +190,232 @@ export function StudioElementsPanel({ page, t, source }: {
|
|||||||
} as BookElement)
|
} as BookElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchedIcons = useMemo(() => searchIcons(iconQuery), [iconQuery])
|
/** A block of empty frames, selected as a group so it moves and resizes as one thing straight away. */
|
||||||
const shownIcons = useMemo(() => matchedIcons.slice(0, iconLimit), [matchedIcons, iconLimit])
|
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 || on.elements.length + els.length > MAX_SPREAD_ELEMENTS) return
|
||||||
|
commit(d => ({
|
||||||
|
...d,
|
||||||
|
spreads: d.spreads.map((sp, i) => (i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
|
||||||
|
}))
|
||||||
|
select(els.map(e => e.id))
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
return { active, single, addElement, addText, addShape, addIcon, addFrame, addGrid }
|
||||||
* Grow the grid when its foot comes into view.
|
}
|
||||||
*
|
|
||||||
* An observer rather than a scroll handler: the panel is the scroller, the
|
type Adders = ReturnType<typeof useElementAdders>
|
||||||
* foot is the thing that matters, and asking the browser to tell us when they
|
|
||||||
* meet costs nothing per frame.
|
/** 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 }: {
|
||||||
useEffect(() => {
|
page: BookPageSetup
|
||||||
const tail = iconTail.current
|
source: ShelfSource
|
||||||
if (!tail) return
|
t: (k: string) => string
|
||||||
const io = new IntersectionObserver(entries => {
|
addText: AddText
|
||||||
if (entries.some(e => e.isIntersecting)) setIconLimit(n => n + ICON_PAGE)
|
}) {
|
||||||
})
|
return (
|
||||||
io.observe(tail)
|
<>
|
||||||
return () => io.disconnect()
|
<Shelf label={t('journey.studio.text')}>
|
||||||
}, [shownIcons.length, matchedIcons.length])
|
<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)}
|
||||||
|
aria-label={shape}
|
||||||
|
>
|
||||||
|
<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)}
|
||||||
|
aria-label={shape}
|
||||||
|
>
|
||||||
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PanelHead label={t('journey.studio.elements')} />
|
<PanelHead label={t('journey.studio.elements')} />
|
||||||
|
|
||||||
<div className="st-tabs is-fluid">
|
<StudioTabs<Tab>
|
||||||
<button type="button" className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
|
idBase={tabsId}
|
||||||
{t('journey.studio.text')}
|
label={t('journey.studio.elements')}
|
||||||
</button>
|
value={tab}
|
||||||
<button type="button" className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}>
|
onChange={setTab}
|
||||||
{t('journey.studio.shapes')}
|
columns={3}
|
||||||
</button>
|
tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))}
|
||||||
<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>
|
|
||||||
|
|
||||||
<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
|
The icons and the stickers carry a search of their own, which sits
|
||||||
drawn with the book's own words (StudioTextStyles.tsx).
|
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 === 'icons' ? (
|
||||||
|
<StudioIconShelf onPick={adders.addIcon} t={t} />
|
||||||
{tab === 'shapes' && (
|
) : tab === 'stickers' ? (
|
||||||
<>
|
<StudioStickersPanel page={page} t={t} />
|
||||||
<div className="st-section">
|
) : (
|
||||||
<div className="st-section-label">{t('journey.studio.lines')}</div>
|
<div className="st-panel-scroll">
|
||||||
<div className="st-shape-grid">
|
{tab === 'text' && <TextTab page={page} source={source} t={t} addText={adders.addText} />}
|
||||||
<button type="button" className="st-shape-btn" onClick={() => addShape('line')} title={t('journey.studio.shapeKind.line')}>
|
{tab === 'shapes' && <ShapesTab addShape={adders.addShape} t={t} />}
|
||||||
<Minus size={20} strokeWidth={2} />
|
{tab === 'lines' && (
|
||||||
</button>
|
<StudioLineShelf
|
||||||
<button type="button"
|
page={page}
|
||||||
className="st-shape-btn"
|
single={adders.single}
|
||||||
onClick={() => addShape('rect', { outline: true })}
|
onAdd={el => adders.addElement(adders.active, el)}
|
||||||
title={t('journey.studio.shapeKind.outline')}
|
t={t}
|
||||||
>
|
lead={(
|
||||||
<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]) => (
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
key={style}
|
className="st-shape-btn"
|
||||||
className="st-shape-btn is-frame"
|
onClick={() => adders.addShape('line')}
|
||||||
onClick={() => addFrame(null, style)}
|
title={t('journey.studio.shapeKind.line')}
|
||||||
title={t(`journey.studio.${key}`)}
|
aria-label={t('journey.studio.shapeKind.line')}
|
||||||
aria-label={t(`journey.studio.${key}`)}
|
|
||||||
>
|
>
|
||||||
<FrameStyleGlyph style={style} />
|
<Minus size={20} strokeWidth={2} aria-hidden />
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</StudioTabPanel>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,25 @@
|
|||||||
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared'
|
import type { BookElement, BookPageNumbers, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
|
||||||
import {
|
import { withThemeLinks } from '@trek/shared'
|
||||||
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp,
|
import { ClipboardCopy, Copy, Lock, Trash2, Unlock } from 'lucide-react'
|
||||||
ChevronsDown, ChevronsUp, ClipboardCopy, Copy, Italic, Lock, Trash2, Unlock,
|
import type { ReactNode } from 'react'
|
||||||
} from 'lucide-react'
|
|
||||||
import { useMemo, useRef, useState } from 'react'
|
|
||||||
import { useStudioStore } from '../../store/studioStore'
|
import { useStudioStore } from '../../store/studioStore'
|
||||||
import { photoSrc } from './bookRender'
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
|
import '../../styles/studio-panels.css'
|
||||||
import { ShapeGlyph } from './StudioElementsPanel'
|
import { PanelHead } from './StudioPanelHead'
|
||||||
|
import { selectionTitle } from './studioLayerRows'
|
||||||
|
import { StudioArrangeSection } from './StudioArrangeSection'
|
||||||
import { TravelInspector } from './StudioTravelInspector'
|
import { TravelInspector } from './StudioTravelInspector'
|
||||||
import { StudioStickerInspector } from './StudioStickerInspector'
|
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 { LineInspector } from './StudioLineInspector'
|
||||||
import { StudioThemeLink } from './theme/StudioThemeLink'
|
import { StudioDocumentInspector } from './inspector/StudioDocumentInspector'
|
||||||
|
import { StudioPositionSection } from './inspector/StudioPositionSection'
|
||||||
/** How many icons the swap grid offers at once. A search narrows it below this. */
|
import { StudioTextInspector } from './inspector/StudioTextInspector'
|
||||||
const ICON_CHOICES = 60
|
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.
|
* Properties of whatever is selected.
|
||||||
@@ -42,24 +31,27 @@ const ICON_CHOICES = 60
|
|||||||
* out reads as a settings dialog, not as a tool.
|
* 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
|
* 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**,
|
* memory more than by reading:
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* The frame's numbers are folded away by default. They are the least-used group
|
* 1. **what it is and what it says**: a text's words, a photo's crop, a
|
||||||
* in the panel — dragging is how anyone actually positions an element — and
|
* shape's outline, a sticker's colours, a map's scope and framing;
|
||||||
* four number fields left open at the top pushed everything worth looking at
|
* 2. **where it sits**: the position and size, folded unless the element is
|
||||||
* below the fold.
|
* 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
|
* 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, and a group
|
* their own: they act on the element rather than describing it.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
type Set = (patch: Partial<BookElement>) => void
|
||||||
|
|
||||||
export function StudioInspector({
|
export function StudioInspector({
|
||||||
spreadIndex,
|
spreadIndex,
|
||||||
page,
|
page,
|
||||||
@@ -69,6 +61,7 @@ export function StudioInspector({
|
|||||||
setPageNumbers,
|
setPageNumbers,
|
||||||
t,
|
t,
|
||||||
locale,
|
locale,
|
||||||
|
onOpenPanel,
|
||||||
}: {
|
}: {
|
||||||
spreadIndex: number
|
spreadIndex: number
|
||||||
page: BookPageSetup
|
page: BookPageSetup
|
||||||
@@ -81,126 +74,35 @@ export function StudioInspector({
|
|||||||
source: JourneySource
|
source: JourneySource
|
||||||
t: (k: string) => string
|
t: (k: string) => string
|
||||||
locale: 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 doc = useStudioStore(s => s.doc)
|
||||||
const selection = useStudioStore(s => s.selection)
|
const selection = useStudioStore(s => s.selection)
|
||||||
const update = useStudioStore(s => s.updateElement)
|
const update = useStudioStore(s => s.updateElement)
|
||||||
const commit = useStudioStore(s => s.commit)
|
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 spread = doc?.spreads[spreadIndex]
|
||||||
const sel = spread?.elements.filter(e => selection.includes(e.id)) ?? []
|
const sel = spread?.elements.filter(e => selection.includes(e.id)) ?? []
|
||||||
|
const el = sel[0]
|
||||||
|
|
||||||
if (!sel.length) {
|
if (!el) {
|
||||||
/*
|
|
||||||
* 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
|
|
||||||
return (
|
return (
|
||||||
<aside className="st-panel st-inspector">
|
<StudioDocumentInspector
|
||||||
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div>
|
spreadIndex={spreadIndex}
|
||||||
<div className="st-panel-scroll">
|
page={page}
|
||||||
<StudioBackgroundSection spreadIndex={spreadIndex} page={page} source={source} t={t} />
|
source={source}
|
||||||
|
setPageNumbers={setPageNumbers}
|
||||||
<Section label={t('journey.studio.pageNumbers')}>
|
onOpenTheme={onOpenPanel && (() => onOpenPanel('theme'))}
|
||||||
<Switch
|
t={t}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = sel[0]
|
|
||||||
const one = sel.length === 1
|
const one = sel.length === 1
|
||||||
|
|
||||||
// A property edit is one undo step on its own; only drags open a gesture.
|
// 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,
|
...d,
|
||||||
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
|
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
|
||||||
...sp,
|
...sp,
|
||||||
@@ -209,581 +111,130 @@ export function StudioInspector({
|
|||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const live = (patch: Partial<BookElement>) => update(spreadIndex, el.id, patch)
|
const live: Set = patch => 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)] }
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="st-panel st-inspector">
|
<aside className="st-panel st-inspector">
|
||||||
{/*
|
{/*
|
||||||
The head says what is selected and carries the three things you do TO
|
The head says what is selected and carries the things you do TO it, in
|
||||||
it. It used to say only the kind, in the same small capitals as every
|
the head every panel opens on.
|
||||||
section label below it, which left the panel with no top.
|
|
||||||
*/}
|
*/}
|
||||||
<div className="st-panel-head st-inspector-head">
|
<PanelHead
|
||||||
<span className="st-what">
|
label={one ? t(`journey.studio.kind.${el.kind}`) : selectionTitle(spread, selection, t)}
|
||||||
{one ? t(`journey.studio.kind.${el.kind}`) : selectionTitle(spread, selection, t)}
|
count={sel.length > 1 ? sel.length : undefined}
|
||||||
{sel.length > 1 && <em>{sel.length}</em>}
|
actions={<SelectionActions spreadIndex={spreadIndex} el={el} selection={selection} set={set} t={t} />}
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="st-panel-scroll">
|
<div className="st-panel-scroll" key={selectionKey(selection)}>
|
||||||
{el.kind === 'text' && (
|
<KindInspector
|
||||||
<>
|
el={el}
|
||||||
<Section label={t('journey.studio.text')}>
|
one={one}
|
||||||
<textarea
|
spreadIndex={spreadIndex}
|
||||||
className="st-input st-textarea"
|
spread={spread}
|
||||||
value={el.text}
|
page={page}
|
||||||
rows={5}
|
selection={selection}
|
||||||
onChange={e => live({ text: e.target.value, overridden: true })}
|
stats={stats}
|
||||||
onBlur={e => set({ text: e.target.value, overridden: true })}
|
path={path}
|
||||||
/>
|
source={source}
|
||||||
{el.binding && !el.overridden && (
|
set={set}
|
||||||
<p className="st-hint" style={{ paddingTop: 6 }}>{t('journey.studio.boundHint')}</p>
|
live={live}
|
||||||
)}
|
position={<StudioPositionSection el={el} sel={sel} set={set} t={t} />}
|
||||||
{/*
|
t={t}
|
||||||
How a point is set. Only a coordinate binding has the question,
|
locale={locale}
|
||||||
and answering it re-sets the words on the spot rather than at
|
/>
|
||||||
the next open — switching a notation and seeing nothing happen
|
<StudioArrangeSection
|
||||||
reads as a broken control. Deliberately not `overridden`:
|
spreadIndex={spreadIndex}
|
||||||
choosing a format is not writing the text.
|
t={t}
|
||||||
*/}
|
onShowLayers={onOpenPanel && (() => onOpenPanel('layers'))}
|
||||||
{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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Copy to another page, duplicate, lock and delete: what you do to the selection, in the head. */
|
||||||
* Where the picture is anchored inside its frame.
|
function SelectionActions({ spreadIndex, el, selection, set, t }: {
|
||||||
*
|
spreadIndex: number
|
||||||
* Two sliders were the first draft and they were the wrong instrument: a focal
|
el: BookElement
|
||||||
* point is one position in a plane, not two unrelated numbers, and nobody can
|
selection: string[]
|
||||||
* predict what "X 0.62" does to a photograph. Dragging the point over the actual
|
set: Set
|
||||||
* image answers the question by showing it.
|
t: (k: string) => string
|
||||||
*/
|
|
||||||
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 duplicate = useStudioStore(s => s.duplicate)
|
||||||
const last = useRef({ x, y })
|
const copy = useStudioStore(s => s.copy)
|
||||||
|
const removeElements = useStudioStore(s => s.removeElements)
|
||||||
const from = (e: React.PointerEvent) => {
|
const lockLabel = t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="st-focal-wrap">
|
<>
|
||||||
<div
|
<Tooltip label={t('journey.studio.copyToPage')}>
|
||||||
className="st-focal"
|
<button type="button" className="st-act" onClick={() => copy(spreadIndex, selection)}
|
||||||
ref={box}
|
aria-label={t('journey.studio.copyToPage')}>
|
||||||
onPointerDown={e => {
|
<ClipboardCopy size={14} />
|
||||||
(e.target as Element).setPointerCapture(e.pointerId)
|
</button>
|
||||||
const p = from(e)
|
</Tooltip>
|
||||||
onDrag(p.x, p.y)
|
<button type="button" className="st-act" onClick={() => duplicate(spreadIndex, selection)}
|
||||||
}}
|
title={t('journey.studio.duplicate')} aria-label={t('journey.studio.duplicate')}>
|
||||||
onPointerMove={e => {
|
<Copy size={14} />
|
||||||
if (!e.currentTarget.hasPointerCapture?.(e.pointerId)) return
|
</button>
|
||||||
const p = from(e)
|
<button type="button" className={`st-act ${el.locked ? 'is-on' : ''}`} onClick={() => set({ locked: !el.locked })}
|
||||||
onDrag(p.x, p.y)
|
title={lockLabel} aria-label={lockLabel} aria-pressed={el.locked}>
|
||||||
}}
|
{el.locked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||||
onPointerUp={() => onCommit(last.current.x, last.current.y)}
|
</button>
|
||||||
>
|
<button type="button" className="st-act is-danger" onClick={() => removeElements(spreadIndex, selection)}
|
||||||
<img src={photoSrc(photoId, false)} alt="" draggable={false} />
|
title={t('journey.studio.delete')} aria-label={t('journey.studio.delete')}>
|
||||||
<span className="st-focal-dot" style={{ left: `${x * 100}%`, top: `${y * 100}%` }} />
|
<Trash2 size={14} />
|
||||||
</div>
|
</button>
|
||||||
<p className="st-hint" style={{ padding: '6px 0 0' }}>{hint}</p>
|
</>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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);
|
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', () => {
|
it('offers no drag, lock or visibility to a book held read-only', () => {
|
||||||
useStudioStore.setState({ readOnly: true });
|
useStudioStore.setState({ readOnly: true });
|
||||||
const { container } = render(<StudioLayersPanel t={t} />);
|
const { container } = render(<StudioLayersPanel t={t} />);
|
||||||
@@ -175,7 +235,8 @@ describe('the align section', () => {
|
|||||||
store().select(['a']);
|
store().select(['a']);
|
||||||
render(<StudioArrangeSection spreadIndex={0} t={t} />);
|
render(<StudioArrangeSection spreadIndex={0} t={t} />);
|
||||||
// Folded for one element: lining up is not what one element usually needs next.
|
// 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();
|
expect(screen.getByRole('radio', { name: 'journey.studio.layers.toSelection' })).toBeDisabled();
|
||||||
fireEvent.click(screen.getByLabelText('journey.studio.layers.alignRight'));
|
fireEvent.click(screen.getByLabelText('journey.studio.layers.alignRight'));
|
||||||
expect(el('a').frame.x).toBe(190);
|
expect(el('a').frame.x).toBe(190);
|
||||||
|
|||||||
@@ -27,7 +27,17 @@ import { useStudioStore } from '../../store/studioStore';
|
|||||||
import '../../styles/studio-layers.css';
|
import '../../styles/studio-layers.css';
|
||||||
import { Tooltip } from '../shared/Tooltip';
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
import { PanelHead } from './StudioPanelHead';
|
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';
|
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
|
* 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,
|
* 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.
|
* 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> = {
|
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 [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||||
const [renaming, setRenaming] = useState<string | null>(null);
|
const [renaming, setRenaming] = useState<string | null>(null);
|
||||||
const [drag, setDrag] = useState<Drag | 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);
|
const list = useRef<HTMLDivElement>(null);
|
||||||
/** A drag ends in a click on the row it started from; that click is not a choice. */
|
/** A drag ends in a click on the row it started from; that click is not a choice. */
|
||||||
const justDragged = useRef(false);
|
const justDragged = useRef(false);
|
||||||
@@ -114,14 +132,55 @@ export function StudioLayersPanel({ t }: { t: T }) {
|
|||||||
return next;
|
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) => {
|
const choose = (row: LayerRow, additive: boolean) => {
|
||||||
if (justDragged.current) {
|
if (justDragged.current) {
|
||||||
justDragged.current = false;
|
justDragged.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.type === 'group') selectGroup(spreadIndex, row.groupId, additive);
|
selectRow(row, additive);
|
||||||
else if (row.inGroup && !additive) enterGroup(spreadIndex, row.el.id);
|
};
|
||||||
else pick(spreadIndex, row.el.id, 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. */
|
/** 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' : ''}`}
|
className={`st-layers ${drag?.active ? 'is-dragging' : ''}`}
|
||||||
role="tree"
|
role="tree"
|
||||||
aria-label={t('journey.studio.layers')}
|
aria-label={t('journey.studio.layers')}
|
||||||
|
onKeyDown={onTreeKey}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerCancel={() => setDrag(null)}
|
onPointerCancel={() => setDrag(null)}
|
||||||
@@ -219,6 +279,8 @@ export function StudioLayersPanel({ t }: { t: T }) {
|
|||||||
dragged={!!drag?.active && rowIds(row).every((id) => drag.ids.includes(id))}
|
dragged={!!drag?.active && rowIds(row).every((id) => drag.ids.includes(id))}
|
||||||
dropAt={drag?.active && drag.overKey === row.key ? (drag.upper ? 'above' : 'below') : null}
|
dropAt={drag?.active && drag.overKey === row.key ? (drag.upper ? 'above' : 'below') : null}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
tabStop={row.key === tabKey}
|
||||||
|
onFocusRow={() => setFocusKey(row.key)}
|
||||||
onPointerDown={(e) => onPointerDown(e, row)}
|
onPointerDown={(e) => onPointerDown(e, row)}
|
||||||
onChoose={(additive) => choose(row, additive)}
|
onChoose={(additive) => choose(row, additive)}
|
||||||
onToggleCollapsed={() => row.type === 'group' && toggleCollapsed(row.groupId)}
|
onToggleCollapsed={() => row.type === 'group' && toggleCollapsed(row.groupId)}
|
||||||
@@ -263,6 +325,9 @@ interface RowProps {
|
|||||||
dragged: boolean;
|
dragged: boolean;
|
||||||
dropAt: 'above' | 'below' | null;
|
dropAt: 'above' | 'below' | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
/** The row the Tab key reaches the tree on. */
|
||||||
|
tabStop: boolean;
|
||||||
|
onFocusRow: () => void;
|
||||||
onPointerDown: (e: React.PointerEvent) => void;
|
onPointerDown: (e: React.PointerEvent) => void;
|
||||||
onChoose: (additive: boolean) => void;
|
onChoose: (additive: boolean) => void;
|
||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
@@ -312,8 +377,11 @@ function LayerRowView(p: RowProps) {
|
|||||||
className={rowClasses(p, hidden)}
|
className={rowClasses(p, hidden)}
|
||||||
data-layer-key={row.key}
|
data-layer-key={row.key}
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
|
tabIndex={p.tabStop ? 0 : -1}
|
||||||
|
aria-level={row.type === 'element' && row.inGroup ? 2 : 1}
|
||||||
aria-selected={p.selected}
|
aria-selected={p.selected}
|
||||||
aria-expanded={row.type === 'group' ? !p.collapsed : undefined}
|
aria-expanded={row.type === 'group' ? !p.collapsed : undefined}
|
||||||
|
onFocus={p.onFocusRow}
|
||||||
onPointerDown={p.onPointerDown}
|
onPointerDown={p.onPointerDown}
|
||||||
onClick={(e) => p.onChoose(e.shiftKey || e.metaKey || e.ctrlKey)}
|
onClick={(e) => p.onChoose(e.shiftKey || e.metaKey || e.ctrlKey)}
|
||||||
>
|
>
|
||||||
@@ -363,11 +431,14 @@ function RowName(p: {
|
|||||||
onCommit: (value: string) => void;
|
onCommit: (value: string) => void;
|
||||||
onCancel: () => 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) {
|
if (!p.renaming) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`st-layer-name ${p.named ? '' : 'is-auto'}`}
|
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)}
|
onDoubleClick={own(p.onRename)}
|
||||||
>
|
>
|
||||||
{p.label}
|
{p.label}
|
||||||
@@ -383,14 +454,28 @@ function RowName(p: {
|
|||||||
maxLength={60}
|
maxLength={60}
|
||||||
aria-label={p.t('journey.studio.layers.rename')}
|
aria-label={p.t('journey.studio.layers.rename')}
|
||||||
onClick={(e) => e.stopPropagation()}
|
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) => {
|
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;
|
if (e.key !== 'Escape') return;
|
||||||
// Escape here leaves the name as it was, and must not close Studio.
|
// Escape here leaves the name as it was, and must not close Studio.
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
cancelled.current = true;
|
||||||
p.onCancel();
|
p.onCancel();
|
||||||
|
row?.focus();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,17 +7,21 @@ import {
|
|||||||
bookLineStyled,
|
bookLineStyled,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
import { ArrowLeftRight } from 'lucide-react';
|
import { ArrowLeftRight } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { useStudioStore } from '../../store/studioStore';
|
import { useStudioStore } from '../../store/studioStore';
|
||||||
|
import { ornamentInUse } from './inspector/inspectorModel';
|
||||||
|
import { lineGeometry } from './lineRender';
|
||||||
import { LinePaths } from './LineView';
|
import { LinePaths } from './LineView';
|
||||||
import { Choice, Line, NumField, Section } from './StudioControls';
|
import { Choice, Line, NumField, Section } from './StudioControls';
|
||||||
|
import { STROKE_STYLE_OPTIONS } from './studioOptions';
|
||||||
import { Swatches } from './StudioSwatches';
|
import { Swatches } from './StudioSwatches';
|
||||||
import { lineGeometry } from './lineRender';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The inspector of a drawn line: its colour and weight, dashes, the course it
|
* The inspector of a drawn line: the course it takes first (straight or
|
||||||
* takes, a wave or a zigzag, the hand-drawn wobble, what sits on each end and
|
* smooth, a wave or a zigzag, the hand-drawn wobble), then where it sits, then
|
||||||
* in the middle.
|
* 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,
|
* 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
|
* 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({
|
export function LineInspector({
|
||||||
el,
|
el,
|
||||||
spreadIndex,
|
spreadIndex,
|
||||||
|
position,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
el: BookLineElement;
|
el: BookLineElement;
|
||||||
spreadIndex: number;
|
spreadIndex: number;
|
||||||
|
/** The position and size group, which every kind places after what it is. */
|
||||||
|
position: ReactNode;
|
||||||
t: (k: string) => string;
|
t: (k: string) => string;
|
||||||
}) {
|
}) {
|
||||||
const commit = useStudioStore((s) => s.commit);
|
const commit = useStudioStore((s) => s.commit);
|
||||||
@@ -119,19 +126,29 @@ export function LineInspector({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section label={t('journey.studio.kind.line')}>
|
<Section label={t('journey.studio.kind.line')}>
|
||||||
<Line label={t('journey.studio.stroke')} wide>
|
<Line label={t('journey.studio.lineCourseTitle')} wide>
|
||||||
<Swatches value={el.stroke} onPick={(c) => set({ stroke: c })} />
|
<Choice
|
||||||
</Line>
|
value={el.curve}
|
||||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
ariaLabel={t('journey.studio.lineCourseTitle')}
|
||||||
<NumField
|
options={(['straight', 'smooth'] as const).map((value) => ({
|
||||||
label={t('journey.studio.strokeWidth')}
|
value,
|
||||||
value={el.strokeWidth}
|
label: t(`journey.studio.lineCourse.${value}`),
|
||||||
min={0.05}
|
}))}
|
||||||
max={20}
|
onPick={(curve) => set({ curve })}
|
||||||
step={0.05}
|
|
||||||
unit="mm"
|
|
||||||
onChange={(v) => set({ strokeWidth: v })}
|
|
||||||
/>
|
/>
|
||||||
|
</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
|
<NumField
|
||||||
label={t('journey.studio.wobble')}
|
label={t('journey.studio.wobble')}
|
||||||
value={Math.round(el.wobble * 100)}
|
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)) })}
|
onChange={(v) => set({ wobble: Math.min(1, Math.max(0, v / 100)) })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 10 }}>
|
<p className="st-hint st-hint-after">{t('journey.studio.linePointsHint')}</p>
|
||||||
<Line label={t('journey.studio.strokeStyle')} wide>
|
</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
|
<Choice
|
||||||
value={el.dash}
|
value={el.dash}
|
||||||
options={[
|
ariaLabel={t('journey.studio.strokeStyle')}
|
||||||
{ value: 'solid' as const, label: t('journey.studio.strokeSolid') },
|
options={STROKE_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||||
{ value: 'dashed' as const, label: t('journey.studio.strokeDashed') },
|
|
||||||
{ value: 'dotted' as const, label: t('journey.studio.strokeDotted') },
|
|
||||||
]}
|
|
||||||
onPick={(dash) => set({ dash })}
|
onPick={(dash) => set({ dash })}
|
||||||
/>
|
/>
|
||||||
</Line>
|
</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>
|
</div>
|
||||||
<p className="st-hint" style={{ paddingTop: 8 }}>
|
|
||||||
{t('journey.studio.linePointsHint')}
|
|
||||||
</p>
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section label={t('journey.studio.lineEnds')}>
|
<Section label={t('journey.studio.lineEnds')}>
|
||||||
@@ -187,7 +196,7 @@ export function LineInspector({
|
|||||||
<Line label={t('journey.studio.lineEnd')} wide>
|
<Line label={t('journey.studio.lineEnd')} wide>
|
||||||
<Choice value={el.endCap} options={caps(false)} onPick={(endCap) => set({ endCap })} />
|
<Choice value={el.endCap} options={caps(false)} onPick={(endCap) => set({ endCap })} />
|
||||||
</Line>
|
</Line>
|
||||||
<div className="st-row" style={{ marginTop: 6 }}>
|
<div className="st-row st-section-gap">
|
||||||
<button type="button" className="st-chip" onClick={swapEnds}>
|
<button type="button" className="st-chip" onClick={swapEnds}>
|
||||||
<ArrowLeftRight size={13} />
|
<ArrowLeftRight size={13} />
|
||||||
{t('journey.studio.swapEnds')}
|
{t('journey.studio.swapEnds')}
|
||||||
@@ -195,7 +204,7 @@ export function LineInspector({
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section label={t('journey.studio.ornamentTitle')} defaultOpen={el.ornament !== 'none'}>
|
<Section label={t('journey.studio.ornamentTitle')} defaultOpen={ornamentInUse(el)}>
|
||||||
<Choice
|
<Choice
|
||||||
value={el.ornament}
|
value={el.ornament}
|
||||||
options={BOOK_LINE_ORNAMENTS.map((o) => ({
|
options={BOOK_LINE_ORNAMENTS.map((o) => ({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { type BookElement, type BookPageSetup, bookElementId, bookNewLine } from '@trek/shared';
|
import { type BookElement, type BookPageSetup, bookElementId, bookNewLine } from '@trek/shared';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { LinePaths } from './LineView';
|
import { LinePaths } from './LineView';
|
||||||
|
import { Shelf } from './StudioPanelHead';
|
||||||
import { DIVIDER_PRESETS, LINE_PRESETS, type LinePreset as Preset, presetPoints } from './linePresets';
|
import { DIVIDER_PRESETS, LINE_PRESETS, type LinePreset as Preset, presetPoints } from './linePresets';
|
||||||
import { lineGeometry } from './lineRender';
|
import { lineGeometry } from './lineRender';
|
||||||
|
|
||||||
@@ -39,11 +41,14 @@ export function StudioLineShelf({
|
|||||||
single,
|
single,
|
||||||
onAdd,
|
onAdd,
|
||||||
t,
|
t,
|
||||||
|
lead,
|
||||||
}: {
|
}: {
|
||||||
page: BookPageSetup;
|
page: BookPageSetup;
|
||||||
single: boolean;
|
single: boolean;
|
||||||
onAdd: (el: BookElement) => void;
|
onAdd: (el: BookElement) => void;
|
||||||
t: (k: string) => string;
|
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 add = (preset: Preset) => {
|
||||||
const origin = single ? 0 : page.pageWidth;
|
const origin = single ? 0 : page.pageWidth;
|
||||||
@@ -52,8 +57,9 @@ export function StudioLineShelf({
|
|||||||
const points = presetPoints(preset, x, page.pageHeight / 2, w);
|
const points = presetPoints(preset, x, page.pageHeight / 2, w);
|
||||||
onAdd(bookNewLine({ ...preset.look, points }, { id: bookElementId('l'), frame: null, inner: !single, page }));
|
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">
|
<div className="st-shape-grid">
|
||||||
|
{first}
|
||||||
{list.map((p) => {
|
{list.map((p) => {
|
||||||
const label = t(`journey.studio.linePreset.${p.id}`);
|
const label = t(`journey.studio.linePreset.${p.id}`);
|
||||||
return (
|
return (
|
||||||
@@ -73,14 +79,8 @@ export function StudioLineShelf({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="st-section">
|
<Shelf label={t('journey.studio.drawnLines')}>{tiles(LINE_PRESETS, lead)}</Shelf>
|
||||||
<div className="st-section-label">{t('journey.studio.drawnLines')}</div>
|
<Shelf label={t('journey.studio.dividers')}>{tiles(DIVIDER_PRESETS)}</Shelf>
|
||||||
{tiles(LINE_PRESETS)}
|
|
||||||
</div>
|
|
||||||
<div className="st-section">
|
|
||||||
<div className="st-section-label">{t('journey.studio.dividers')}</div>
|
|
||||||
{tiles(DIVIDER_PRESETS)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* One head for all of them. The panels on the left used to open on small grey
|
||||||
* import the others — `StudioSidebar` owns the rail, and a panel that reached
|
* capitals and the inspector on a darker title of its own, so the two sides of
|
||||||
* back into it for a header would make the sidebar impossible to split.
|
* 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 (
|
return (
|
||||||
<div className="st-panel-head">
|
<div className="st-panel-head">
|
||||||
<span>{label}</span>
|
<h2 className="st-what">
|
||||||
{count != null && <span style={{ fontVariantNumeric: 'tabular-nums' }}>{count}</span>}
|
<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>
|
</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 BookShapeElement,
|
||||||
type BookTheme,
|
type BookTheme,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
|
import { shapeOutlineInUse } from './inspector/inspectorModel';
|
||||||
import { usePaintChannel } from './paint/usePaintChannel';
|
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 { StudioPaintPicker } from './StudioPaintPicker';
|
||||||
import type { StudioT } from './StudioSidebar';
|
import type { StudioT } from './StudioSidebar';
|
||||||
import { Swatches } from './StudioSwatches';
|
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. */
|
/** The outline a shape is given when its fill goes and it has none, so it never turns invisible. */
|
||||||
const OUTLINE = '#141414';
|
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
|
* 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
|
* 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
|
* outline gives it one, as the old fill switch did: a shape with neither is
|
||||||
* a shape nobody can see or find again.
|
* a shape nobody can see or find again.
|
||||||
*/
|
*/
|
||||||
export function StudioShapePaint({
|
export function StudioShapeFill({
|
||||||
el,
|
el,
|
||||||
spreadIndex,
|
spreadIndex,
|
||||||
selection,
|
selection,
|
||||||
onStroke,
|
|
||||||
theme,
|
theme,
|
||||||
set,
|
set,
|
||||||
t,
|
t,
|
||||||
@@ -36,11 +39,10 @@ export function StudioShapePaint({
|
|||||||
el: BookShapeElement;
|
el: BookShapeElement;
|
||||||
spreadIndex: number;
|
spreadIndex: number;
|
||||||
selection: readonly string[];
|
selection: readonly string[];
|
||||||
onStroke: (color: string) => void;
|
/** The book's theme, for the theme colours under the paint; none without one. */
|
||||||
/** The book's theme, for the theme colours under each row; none without one. */
|
|
||||||
theme?: BookTheme;
|
theme?: BookTheme;
|
||||||
/** The inspector's write, for linking a colour to the theme. */
|
/** The inspector's write, for linking the fill to the theme. */
|
||||||
set?: (patch: Partial<BookShapeElement>) => void;
|
set: SetShape;
|
||||||
t: StudioT;
|
t: StudioT;
|
||||||
}) {
|
}) {
|
||||||
const channel = usePaintChannel((paint) => (doc) => ({
|
const channel = usePaintChannel((paint) => (doc) => ({
|
||||||
@@ -63,31 +65,75 @@ export function StudioShapePaint({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section label={t('journey.studio.colour')}>
|
<Section label={t('journey.studio.fill')}>
|
||||||
<Line label={t('journey.studio.fill')} wide>
|
<StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} />
|
||||||
<StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} />
|
<StudioThemeLink
|
||||||
{set && (
|
theme={theme}
|
||||||
<StudioThemeLink
|
token={el.token}
|
||||||
theme={theme}
|
t={t}
|
||||||
token={el.token}
|
onLink={(token, fill) => set({ fill, token, paint: undefined })}
|
||||||
t={t}
|
onUnlink={() => set({ token: undefined })}
|
||||||
onLink={(token, fill) => set({ fill, token, paint: undefined })}
|
/>
|
||||||
onUnlink={() => set({ token: undefined })}
|
</Section>
|
||||||
/>
|
);
|
||||||
)}
|
}
|
||||||
</Line>
|
|
||||||
<Line label={t('journey.studio.stroke')} wide>
|
/**
|
||||||
<Swatches value={el.stroke ?? OUTLINE} onPick={onStroke} />
|
* A shape's outline: its colour, the stroke it is drawn in and how wide.
|
||||||
{set && (
|
*
|
||||||
<StudioThemeLink
|
* Folded until the shape has one, since most shapes are a fill and nothing
|
||||||
theme={theme}
|
* else. Every way of asking for an outline brings one: a colour, a width or a
|
||||||
token={el.strokeToken}
|
* dash on a shape that had none draws it rather than doing nothing.
|
||||||
t={t}
|
*/
|
||||||
onLink={(strokeToken, stroke) => set({ stroke, strokeToken, strokeWidth: el.strokeWidth || 0.5 })}
|
export function StudioShapeOutline({
|
||||||
onUnlink={() => set({ strokeToken: undefined })}
|
el,
|
||||||
/>
|
theme,
|
||||||
)}
|
set,
|
||||||
</Line>
|
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>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
|
|||||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
|
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
|
||||||
import { foliosOf } from './bookSheets'
|
import { foliosOf } from './bookSheets'
|
||||||
import { StudioSidebar } from './StudioSidebar'
|
import { StudioSidebar } from './StudioSidebar'
|
||||||
|
import type { RailSection } from './sidebar/railSections'
|
||||||
import { ProviderPicker } from '../Journey/JourneyDetailPageProviderPicker'
|
import { ProviderPicker } from '../Journey/JourneyDetailPageProviderPicker'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { StudioCanvas } from './StudioCanvas'
|
import { StudioCanvas } from './StudioCanvas'
|
||||||
@@ -46,6 +47,8 @@ export default function StudioShell() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [bookView, setBookView] = useState(true)
|
const [bookView, setBookView] = useState(true)
|
||||||
const [exporting, setExporting] = useState(false)
|
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 })
|
const assistant = useStudioAssistant({ journeyId: s.journeyId, flush: s.flushBook })
|
||||||
// The book's faces into the offline cache as it opens (bookFontWarm.ts).
|
// The book's faces into the offline cache as it opens (bookFontWarm.ts).
|
||||||
useWarmBookFaces()
|
useWarmBookFaces()
|
||||||
@@ -126,6 +129,8 @@ export default function StudioShell() {
|
|||||||
onToggleStop={s.setStopExcluded}
|
onToggleStop={s.setStopExcluded}
|
||||||
providers={s.photoProviders}
|
providers={s.photoProviders}
|
||||||
onBrowseProvider={s.browseProvider}
|
onBrowseProvider={s.browseProvider}
|
||||||
|
section={railSection}
|
||||||
|
onSection={setRailSection}
|
||||||
/>
|
/>
|
||||||
<Workbench s={s} bookView={bookView} />
|
<Workbench s={s} bookView={bookView} />
|
||||||
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
|
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
|
||||||
@@ -138,6 +143,7 @@ export default function StudioShell() {
|
|||||||
setPageNumbers={s.setPageNumbers}
|
setPageNumbers={s.setPageNumbers}
|
||||||
t={s.t}
|
t={s.t}
|
||||||
locale={s.locale}
|
locale={s.locale}
|
||||||
|
onOpenPanel={setRailSection}
|
||||||
/>
|
/>
|
||||||
</StudioPaletteScope>
|
</StudioPaletteScope>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,50 +1,39 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import type { BookPageSetup, JourneyStats } from '@trek/shared'
|
||||||
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 { StudioElementsPanel } from './StudioElementsPanel'
|
import { StudioElementsPanel } from './StudioElementsPanel'
|
||||||
import { StudioStickersPanel } from './StudioStickersPanel'
|
|
||||||
import { StudioLayersPanel } from './StudioLayersPanel'
|
import { StudioLayersPanel } from './StudioLayersPanel'
|
||||||
import { StudioTravelPanel } from './StudioTravelPanel'
|
import { StudioTravelPanel } from './StudioTravelPanel'
|
||||||
import { StudioThemePanel } from './theme/StudioThemePanel'
|
import { StudioThemePanel } from './theme/StudioThemePanel'
|
||||||
import { StudioSavedLayouts } from './StudioSavedLayouts'
|
import { StudioTemplatesPanel } from './templates/StudioTemplatesPanel'
|
||||||
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
|
import { StudioRail } from './sidebar/StudioRail'
|
||||||
import { StudioContentFilter } from './StudioContentFilter'
|
import { StudioPagesPanel } from './sidebar/StudioPagesPanel'
|
||||||
import { emptyKeyFor, matchingEntries, matchingPhotos, photosFor, type PhotoFilter } from './photoFilter'
|
import { StudioContentPanel } from './sidebar/StudioContentPanel'
|
||||||
|
import { RAIL_IDS, type RailSection } from './sidebar/railSections'
|
||||||
import type { StudioUploader } from './studioUpload'
|
import type { StudioUploader } from './studioUpload'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The left side of Studio: a narrow rail of sections and one wide panel showing
|
* The left side of Studio: a narrow rail of sections and one wide panel showing
|
||||||
* whichever is open.
|
* whichever is open.
|
||||||
*
|
*
|
||||||
* The shape is Canva's and it is the right one here for a plain reason — a book
|
* 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
|
* has a handful of completely different kinds of thing you reach for (which
|
||||||
* from the journey, what to draw, what arrangement), and putting them behind
|
* page, what from the journey, what arrangement, what to draw), and putting
|
||||||
* tabs in a single 200px column would make each of them cramped. An icon rail
|
* them behind tabs in a single 200px column would make each of them cramped.
|
||||||
* costs 52px and gives the panel the room a photo browser actually needs.
|
* 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 {
|
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: {
|
entries: {
|
||||||
id: number
|
id: number
|
||||||
title: string | null
|
title: string | null
|
||||||
@@ -70,29 +59,9 @@ export interface JourneySource {
|
|||||||
/** The translator, with the parameters some of the strings here take. */
|
/** The translator, with the parameters some of the strings here take. */
|
||||||
export type StudioT = (k: string, params?: Record<string, string | number>) => string
|
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({
|
export function StudioSidebar({
|
||||||
page, pxPerMm, bookView, source, stats, path, t, locale,
|
page, pxPerMm, bookView, source, stats, path, t, locale,
|
||||||
canEdit, onUpload, onToggleStop, providers = [], onBrowseProvider,
|
canEdit, onUpload, onToggleStop, providers = [], onBrowseProvider, section, onSection,
|
||||||
}: {
|
}: {
|
||||||
page: BookPageSetup
|
page: BookPageSetup
|
||||||
pxPerMm: number
|
pxPerMm: number
|
||||||
@@ -114,46 +83,38 @@ export function StudioSidebar({
|
|||||||
providers?: { id: string; name: string }[]
|
providers?: { id: string; name: string }[]
|
||||||
/** Opens the provider picker; answers the photo ids that landed, none when closed. */
|
/** Opens the provider picker; answers the photo ids that landed, none when closed. */
|
||||||
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>
|
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 [own, setOwn] = useState<RailSection>('pages')
|
||||||
|
const open = section ?? own
|
||||||
const SECTIONS: { id: Section; icon: typeof Files; labelKey: string }[] = [
|
const setOpen = (next: RailSection) => {
|
||||||
{ id: 'pages', icon: Files, labelKey: 'journey.studio.pages' },
|
setOwn(next)
|
||||||
{ id: 'content', icon: ImageIcon, labelKey: 'journey.studio.content' },
|
onSection?.(next)
|
||||||
{ 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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav className="st-rail" aria-label={t('journey.studio.sections')}>
|
<StudioRail section={open} onSection={setOpen} t={t} />
|
||||||
{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>
|
|
||||||
|
|
||||||
<aside className="st-panel st-side">
|
<aside
|
||||||
{section === 'pages' && <PagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
|
className="st-panel st-side"
|
||||||
{section === 'content' && (
|
id={RAIL_IDS.panel}
|
||||||
<ContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload}
|
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} />
|
providers={providers} onBrowseProvider={onBrowseProvider} />
|
||||||
)}
|
)}
|
||||||
{section === 'elements' && <StudioElementsPanel page={page} t={t} source={source} />}
|
{open === 'templates' && (
|
||||||
{section === 'stickers' && <StudioStickersPanel page={page} t={t} />}
|
<StudioTemplatesPanel page={page} pxPerMm={pxPerMm} t={t} locale={locale} source={source} stats={stats}
|
||||||
{section === 'travel' && (
|
path={path} onOpenContent={() => setOpen('content')} />
|
||||||
|
)}
|
||||||
|
{open === 'elements' && <StudioElementsPanel page={page} t={t} source={source} />}
|
||||||
|
{open === 'travel' && (
|
||||||
<StudioTravelPanel
|
<StudioTravelPanel
|
||||||
page={page}
|
page={page}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
@@ -164,715 +125,9 @@ export function StudioSidebar({
|
|||||||
onToggleStop={onToggleStop}
|
onToggleStop={onToggleStop}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{section === 'layers' && <StudioLayersPanel t={t} />}
|
{open === 'theme' && <StudioThemePanel t={t} />}
|
||||||
{section === 'theme' && <StudioThemePanel t={t} />}
|
{open === 'layers' && <StudioLayersPanel t={t} />}
|
||||||
{section === 'templates' && <TemplatesPanel page={page} pxPerMm={pxPerMm} t={t} onOpenContent={() => setSection('content')} />}
|
|
||||||
</aside>
|
</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 { bookStickerSlotColor, type BookElement, type BookStickerElement } from '@trek/shared';
|
||||||
import { bookSticker } from '@trek/shared/stickers';
|
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 { 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
|
* The inspector of a sticker: its colours slot by slot first, then where it
|
||||||
* lies on the page (mirrored, turned, faded).
|
* 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
|
* 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
|
* 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.
|
* 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;
|
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({
|
export function StudioStickerInspector({
|
||||||
el,
|
el,
|
||||||
set,
|
set,
|
||||||
|
position,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
el: BookStickerElement;
|
el: BookStickerElement;
|
||||||
set: (patch: Partial<BookElement>) => void;
|
set: (patch: Partial<BookElement>) => void;
|
||||||
|
position: ReactNode;
|
||||||
t: Translate;
|
t: Translate;
|
||||||
}) {
|
}) {
|
||||||
const def = bookSticker(el.stickerId);
|
const def = bookSticker(el.stickerId);
|
||||||
@@ -47,7 +45,7 @@ export function StudioStickerInspector({
|
|||||||
</Line>
|
</Line>
|
||||||
))}
|
))}
|
||||||
{el.colors.length > 0 && (
|
{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: [] })}>
|
<button type="button" className="st-chip" onClick={() => set({ colors: [] })}>
|
||||||
{t('journey.studio.stickerResetColours')}
|
{t('journey.studio.stickerResetColours')}
|
||||||
</button>
|
</button>
|
||||||
@@ -55,49 +53,7 @@ export function StudioStickerInspector({
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section label={t('journey.studio.stickerPlacement')}>
|
{position}
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { BOOK_STICKER_THEMES, type BookPageSetup } from '@trek/shared';
|
import { BOOK_STICKER_THEMES, type BookPageSetup } from '@trek/shared';
|
||||||
import { bookStickersOf, type BookStickerDef } from '@trek/shared/stickers';
|
import { bookStickersOf, type BookStickerDef } from '@trek/shared/stickers';
|
||||||
import { Search, X } from 'lucide-react';
|
|
||||||
import { useMemo, useState, type DragEvent } from 'react';
|
import { useMemo, useState, type DragEvent } from 'react';
|
||||||
|
|
||||||
import { useStudioStore } from '../../store/studioStore';
|
import { useStudioStore } from '../../store/studioStore';
|
||||||
import '../../styles/studio-stickers.css';
|
import '../../styles/studio-stickers.css';
|
||||||
import { StickerArt } from './StickerView';
|
import { StickerArt } from './StickerView';
|
||||||
import { PanelHead } from './StudioPanelHead';
|
import { Shelf } from './StudioPanelHead';
|
||||||
|
import { StudioSearch } from './StudioSearch';
|
||||||
import {
|
import {
|
||||||
STICKER_DRAG_TYPE,
|
STICKER_DRAG_TYPE,
|
||||||
matchStickers,
|
matchStickers,
|
||||||
@@ -18,9 +18,10 @@ import {
|
|||||||
} from './stickerLibrary';
|
} from './stickerLibrary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sticker library, as a section of the rail: illustrations to put on a
|
* The sticker library, as the Stickers tab of the Elements panel:
|
||||||
* page, grouped by theme, searchable in the reader's language, with the ones
|
* illustrations to put on a page, grouped by theme, searchable in the
|
||||||
* this viewer placed last on a shelf of their own.
|
* 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
|
* 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
|
* 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PanelHead label={t('journey.studio.stickers')} count={browsing ? undefined : matches.length} />
|
<StudioSearch
|
||||||
<div className="st-search">
|
value={query}
|
||||||
<Search size={14} />
|
onChange={setQuery}
|
||||||
<input
|
label={t('journey.studio.searchStickers')}
|
||||||
value={query}
|
clearLabel={t('common.clear')}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
count={matches.length}
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="st-panel-scroll">
|
<div className="st-panel-scroll">
|
||||||
{browsing && recent.length > 0 && (
|
{browsing && recent.length > 0 && (
|
||||||
<div className="st-section">
|
<Shelf label={t('journey.studio.stickersRecent')}>
|
||||||
<div className="st-section-label">{t('journey.studio.stickersRecent')}</div>
|
|
||||||
<StickerGrid list={recent} t={t} onPick={place} />
|
<StickerGrid list={recent} t={t} onPick={place} />
|
||||||
</div>
|
</Shelf>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Browsing, the library by theme; searching, one grid of what matches. */}
|
{/* Browsing, the library by theme; searching, one grid of what matches. */}
|
||||||
{browsing ? (
|
{browsing ? (
|
||||||
BOOK_STICKER_THEMES.map((id) => (
|
BOOK_STICKER_THEMES.map((id) => (
|
||||||
<div className="st-section" key={id}>
|
<Shelf key={id} label={stickerThemeLabel(t, id)}>
|
||||||
<div className="st-section-label">{stickerThemeLabel(t, id)}</div>
|
|
||||||
<StickerGrid list={bookStickersOf(id)} t={t} onPick={place} />
|
<StickerGrid list={bookStickersOf(id)} t={t} onPick={place} />
|
||||||
</div>
|
</Shelf>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="st-section">
|
<Shelf>
|
||||||
<StickerGrid list={matches} t={t} onPick={place} />
|
<StickerGrid list={matches} t={t} onPick={place} />
|
||||||
{!matches.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
|
{!matches.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
|
||||||
</div>
|
</Shelf>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="st-hint">{t('journey.studio.stickersHint')}</p>
|
<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)
|
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.
|
// 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)
|
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) => {
|
const move = (raw: string) => {
|
||||||
moved.current = Math.max(0, Math.min(100, Number(raw))) / 100
|
moved.current = Math.max(0, Math.min(100, Number(raw))) / 100
|
||||||
|
dirty.current = true
|
||||||
onChange(moved.current, false)
|
onChange(moved.current, false)
|
||||||
}
|
}
|
||||||
const finish = () => onChange(moved.current, true)
|
const finish = () => {
|
||||||
|
if (!dirty.current) return
|
||||||
|
dirty.current = false
|
||||||
|
onChange(moved.current, true)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="st-alpha">
|
<div className="st-alpha">
|
||||||
<div className="st-alpha-track" style={{ ['--st-alpha-colour' as string]: color }}>
|
<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)}
|
onChange={e => move(e.target.value)}
|
||||||
onPointerUp={finish}
|
onPointerUp={finish}
|
||||||
onKeyUp={finish}
|
onKeyUp={finish}
|
||||||
|
onBlur={finish}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="st-alpha-value">{pct}%</span>
|
<span className="st-alpha-value">{pct}%</span>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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-004: 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-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,114 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||||
|
const next = 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(${Math.max(1, Math.min(columns, tabs.length))}, 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 });
|
const toggle = (label: string) => screen.getByRole('button', { name: label });
|
||||||
/** A number field, named by its label, followed by its value and unit. */
|
/** 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) });
|
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', () => {
|
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', () => {
|
it('sets the case and where the lines sit', () => {
|
||||||
const { set } = mount(text());
|
const { set } = mount(text());
|
||||||
|
openPlacement();
|
||||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.caseSmall' }));
|
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.caseSmall' }));
|
||||||
expect(set).toHaveBeenCalledWith({ transform: 'smallcaps' });
|
expect(set).toHaveBeenCalledWith({ transform: 'smallcaps' });
|
||||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.middle' }));
|
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', () => {
|
it('shrinks to fit, and gives the chosen size back when fitting is turned off', () => {
|
||||||
const on = mount(text());
|
const on = mount(text());
|
||||||
|
openPlacement();
|
||||||
fireEvent.click(toggle('journey.studio.fx.fit'));
|
fireEvent.click(toggle('journey.studio.fx.fit'));
|
||||||
expect(on.set).toHaveBeenCalledWith({ fit: 'shrink' });
|
expect(on.set).toHaveBeenCalledWith({ fit: 'shrink' });
|
||||||
on.unmount();
|
on.unmount();
|
||||||
|
|||||||
@@ -12,15 +12,17 @@ import { bookTextIsDrawn } from '@trek/shared';
|
|||||||
|
|
||||||
import '../../styles/studio-text.css';
|
import '../../styles/studio-text.css';
|
||||||
import { photoSrc } from './bookRender';
|
import { photoSrc } from './bookRender';
|
||||||
|
import { FocalPad } from './inspector/FocalPad';
|
||||||
|
import { textEffectsInUse, textPlacementInUse } from './inspector/inspectorModel';
|
||||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls';
|
import { Choice, Line, NumField, Section, Switch } from './StudioControls';
|
||||||
import { FocalPad } from './StudioInspector';
|
|
||||||
import { Swatches } from './StudioSwatches';
|
import { Swatches } from './StudioSwatches';
|
||||||
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
|
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The text inspector's effects: how the letters are cased and where the lines
|
* 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,
|
* 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
|
* 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,
|
* the colour that stands out from the letters at a twentieth of their height,
|
||||||
@@ -49,7 +51,7 @@ const FILL_PHOTOS = 12;
|
|||||||
|
|
||||||
function CaseAndPlace({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
|
function CaseAndPlace({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
|
||||||
return (
|
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')}>
|
<Line label={t('journey.studio.fx.case')}>
|
||||||
<Choice
|
<Choice
|
||||||
value={el.transform ?? 'none'}
|
value={el.transform ?? 'none'}
|
||||||
@@ -352,11 +354,10 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
|||||||
|
|
||||||
export function StudioTextEffects(props: Props) {
|
export function StudioTextEffects(props: Props) {
|
||||||
const { el, set, t } = props;
|
const { el, set, t } = props;
|
||||||
const any = !!(el.outline || el.shadow || el.highlight || el.curve || el.fillPaint);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CaseAndPlace el={el} set={set} t={t} />
|
<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} />
|
<OutlineControls el={el} set={set} t={t} />
|
||||||
<ShadowControls el={el} set={set} t={t} />
|
<ShadowControls el={el} set={set} t={t} />
|
||||||
<BandControls 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 { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { BookDocument, BookTextElement } from '@trek/shared';
|
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 { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
import { useStudioStore } from '../../store/studioStore';
|
import { useStudioStore } from '../../store/studioStore';
|
||||||
@@ -73,11 +73,30 @@ describe('the Text styles shelf', () => {
|
|||||||
expect(elements()).toEqual([]);
|
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', () => {
|
it('opens the Elements panel on the type tab, the plain styles above the lockups', () => {
|
||||||
render(<StudioElementsPanel page={page} t={t} source={source} />);
|
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.styleTitle' })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: 'journey.studio.quoteMark' })).toBeTruthy();
|
||||||
expect(screen.getByRole('button', { name: 'journey.studio.lockup.postmark' })).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();
|
expect(screen.queryByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import type { BookLockup, BookLockupWords, BookPageSetup } from '@trek/shared';
|
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 { useElementSize } from '../../hooks/useElementSize';
|
||||||
import { useStudioStore } from '../../store/studioStore';
|
import { useStudioStore } from '../../store/studioStore';
|
||||||
import '../../styles/studio-text.css';
|
import '../../styles/studio-text.css';
|
||||||
import { ElementView } from './SpreadView';
|
import { ElementView } from './SpreadView';
|
||||||
|
import { Shelf } from './StudioPanelHead';
|
||||||
import { firstFillPhoto } from './textEffects';
|
import { firstFillPhoto } from './textEffects';
|
||||||
import {
|
import {
|
||||||
SHELF_GROUPS,
|
SHELF_GROUPS,
|
||||||
@@ -97,7 +98,8 @@ export function StudioTextStyles({
|
|||||||
|
|
||||||
const place = (lockup: BookLockup) => {
|
const place = (lockup: BookLockup) => {
|
||||||
const els = placeLockup(lockup, { page, spread, words, gallery: source.photos, newId: bookElementId });
|
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 || spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) return;
|
||||||
commit((d) => ({
|
commit((d) => ({
|
||||||
...d,
|
...d,
|
||||||
spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)),
|
spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)),
|
||||||
@@ -109,8 +111,7 @@ export function StudioTextStyles({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{SHELF_GROUPS.map((group) => (
|
{SHELF_GROUPS.map((group) => (
|
||||||
<div className="st-section" key={group.id}>
|
<Shelf key={group.id} label={t(`journey.studio.lockupGroup.${group.id}`)}>
|
||||||
<div className="st-section-label">{t(`journey.studio.lockupGroup.${group.id}`)}</div>
|
|
||||||
<div className="st-lockups">
|
<div className="st-lockups">
|
||||||
{shelfLockups(group.lockups).map((lockup) => (
|
{shelfLockups(group.lockups).map((lockup) => (
|
||||||
<LockupTile
|
<LockupTile
|
||||||
@@ -123,7 +124,7 @@ export function StudioTextStyles({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Shelf>
|
||||||
))}
|
))}
|
||||||
<p className="st-hint st-fx-hint">{t('journey.studio.textStylesHint')}</p>
|
<p className="st-hint st-fx-hint">{t('journey.studio.textStylesHint')}</p>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
import type {
|
import type {
|
||||||
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
||||||
BookMetric, BookStatsElement, JourneyStats,
|
BookMetric, BookStatsElement, JourneyStats,
|
||||||
@@ -14,14 +14,19 @@ import { Choice, Line, Picks, Section, Switch } from './StudioControls'
|
|||||||
import { fetchRoads } from './roadRoute'
|
import { fetchRoads } from './roadRoute'
|
||||||
import { useStudioStore } from '../../store/studioStore'
|
import { useStudioStore } from '../../store/studioStore'
|
||||||
import { StudioThemeLink } from './theme/StudioThemeLink'
|
import { StudioThemeLink } from './theme/StudioThemeLink'
|
||||||
|
import { mapZoomInUse, travelTypeInUse } from './inspector/inspectorModel'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Properties of the travel elements.
|
* 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.
|
* 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
|
* Keeping them here leaves the inspector about the elements that have been
|
||||||
* been there since the beginning.
|
* 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 {
|
export interface TravelInspectorProps {
|
||||||
@@ -33,6 +38,8 @@ export interface TravelInspectorProps {
|
|||||||
set: (patch: Partial<BookElement>) => void
|
set: (patch: Partial<BookElement>) => void
|
||||||
t: (k: string) => string
|
t: (k: string) => string
|
||||||
locale: string
|
locale: string
|
||||||
|
/** The position and size group, which every kind places after what it is. */
|
||||||
|
position: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
@@ -41,7 +48,7 @@ interface Props<T> {
|
|||||||
t: (k: string) => string
|
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)
|
const theme = useStudioStore(s => s.doc?.theme)
|
||||||
if (
|
if (
|
||||||
el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries'
|
el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries'
|
||||||
@@ -70,12 +77,16 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
|
|||||||
</div>
|
</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 === 'stats' && <StatsProps el={el} set={set} t={t} />}
|
||||||
{el.kind === 'countries' && <CountriesProps 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 === 'badge' && <BadgeProps el={el} set={set} t={t} />}
|
||||||
{el.kind === 'list' && <ListProps 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.
|
* Type, for all five alike.
|
||||||
*
|
*
|
||||||
@@ -83,11 +94,12 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
|
|||||||
* country's name over its outline — and until now the only thing you
|
* 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
|
* 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
|
* 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} />
|
<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
|
<WeightLine
|
||||||
font={el.font}
|
font={el.font}
|
||||||
weight={el.weight}
|
weight={el.weight}
|
||||||
@@ -147,7 +159,7 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
|
|||||||
onUnlink={() => set({ token: undefined } as Partial<BookElement>)} />
|
onUnlink={() => set({ token: undefined } as Partial<BookElement>)} />
|
||||||
</Line>
|
</Line>
|
||||||
{el.kind === 'badge' && (
|
{el.kind === 'badge' && (
|
||||||
<div style={{ marginTop: 10 }}>
|
<div className="st-section-gap">
|
||||||
<Switch
|
<Switch
|
||||||
label={t('journey.studio.autoColour')}
|
label={t('journey.studio.autoColour')}
|
||||||
on={el.autoColor}
|
on={el.autoColor}
|
||||||
@@ -208,31 +220,9 @@ type MapPropsInput = Props<BookMapElement> & {
|
|||||||
journeyPath: [number, number][][]
|
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 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 })
|
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. */
|
/** 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)
|
const tripsWithRoute = (stats?.trips ?? []).filter(t => t.points > 0)
|
||||||
@@ -328,7 +318,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
|||||||
{el.source !== 'vector' && (
|
{el.source !== 'vector' && (
|
||||||
<>
|
<>
|
||||||
<p className="st-hint" style={{ paddingTop: 8 }}>
|
<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>
|
</p>
|
||||||
{/*
|
{/*
|
||||||
What this map will actually print at, at the size it is now.
|
What this map will actually print at, at the size it is now.
|
||||||
@@ -338,7 +328,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
|||||||
{dpi != null && (
|
{dpi != null && (
|
||||||
<p className={`st-hint ${dpi < 150 ? 'st-stale' : ''}`} style={{ paddingTop: 6 }}>
|
<p className={`st-hint ${dpi < 150 ? 'st-stale' : ''}`} style={{ paddingTop: 6 }}>
|
||||||
{t('journey.studio.mapPrintDpi')} {dpi} dpi
|
{t('journey.studio.mapPrintDpi')} {dpi} dpi
|
||||||
{dpi < 150 && ` — ${t('journey.studio.mapPrintDpiLow')}`}
|
{dpi < 150 && `, ${t('journey.studio.mapPrintDpiLow')}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -408,7 +398,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{el.source === 'tiles' && (
|
{el.source === 'tiles' && (
|
||||||
<Section label={t('journey.studio.mapZoom')} defaultOpen={false}>
|
<Section label={t('journey.studio.mapZoom')} defaultOpen={mapZoomInUse(el)}>
|
||||||
<Choice
|
<Choice
|
||||||
value={el.zoom ?? -1}
|
value={el.zoom ?? -1}
|
||||||
options={[
|
options={[
|
||||||
@@ -419,7 +409,40 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</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.
|
How the line itself is drawn, and whether it follows real roads.
|
||||||
|
|
||||||
@@ -675,9 +698,9 @@ function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** What each imagery source is for, said once where it is chosen. */
|
/** What each imagery source is for, said once where it is chosen. */
|
||||||
const SOURCE_HINT: Record<string, string> = {
|
const SOURCE_HINT: Record<string, { hintKey: string }> = {
|
||||||
relief: 'journey.studio.mapSourceReliefHint',
|
relief: { hintKey: 'journey.studio.mapSourceReliefHint' },
|
||||||
satellite: 'journey.studio.mapSourceSatelliteHint',
|
satellite: { hintKey: 'journey.studio.mapSourceSatelliteHint' },
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The variants that draw something as well as saying something. */
|
/** The variants that draw something as well as saying something. */
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Check, RouteOff } from 'lucide-react'
|
import { Check, RouteOff } from 'lucide-react'
|
||||||
import type {
|
import type {
|
||||||
BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, BookTravelContext, JourneyStats,
|
BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, JourneyStats,
|
||||||
} from '@trek/shared'
|
} from '@trek/shared'
|
||||||
import {
|
import {
|
||||||
BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge,
|
BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge,
|
||||||
bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats,
|
bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats,
|
||||||
} from '@trek/shared'
|
} from '@trek/shared'
|
||||||
import { useStudioStore } from '../../store/studioStore'
|
import { useStudioStore } from '../../store/studioStore'
|
||||||
import { PanelHead } from './StudioPanelHead'
|
import { PanelHead, Shelf } from './StudioPanelHead'
|
||||||
|
import { studioTravelContext } from './studioTravelContext'
|
||||||
import { Section } from './StudioControls'
|
import { Section } from './StudioControls'
|
||||||
import { TravelPreview } from './TravelPreview'
|
import { TravelPreview } from './TravelPreview'
|
||||||
import { fetchRoads } from './roadRoute'
|
import { fetchRoads } from './roadRoute'
|
||||||
@@ -84,10 +85,10 @@ function Card({ label, onClick, children, recommended = false, title }: {
|
|||||||
/**
|
/**
|
||||||
* Marked as the one to reach for, with a ring rather than with words.
|
* 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 ·
|
* 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
|
* recommended" would take both of its lines. A ring says the same thing in
|
||||||
* space at all, and the tooltip carries the word for anyone who wonders why
|
* no space at all, and the tooltip carries the word for anyone who wonders
|
||||||
* this card looks different.
|
* why this card looks different.
|
||||||
*/
|
*/
|
||||||
recommended?: boolean
|
recommended?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
@@ -97,7 +98,7 @@ function Card({ label, onClick, children, recommended = false, title }: {
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
|
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={title ? `${label} — ${title}` : label}
|
title={title ? `${label}: ${title}` : label}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<span className="st-travel-card-label">{label}</span>
|
<span className="st-travel-card-label">{label}</span>
|
||||||
@@ -167,21 +168,11 @@ export function StudioTravelPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* What the book engine's factories read: the journey, its recorded way and
|
* What the book engine's factories read (studioTravelContext.ts). Every tile
|
||||||
* the words in the reader's language. Every tile below is built by the
|
* below is built by the factory the engine's `add` calls. Where it lands is
|
||||||
* factory the engine's `add` calls. Where it lands is the panel's own:
|
* the panel's own: centred across the spread, where `add` takes the right page.
|
||||||
* 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 = {
|
const travel = studioTravelContext(stats, path, locale, t)
|
||||||
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 at = (size: { w: number; h: number }) => centre(size.w, size.h)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -352,8 +343,7 @@ export function StudioTravelPanel({
|
|||||||
<>
|
<>
|
||||||
<PanelHead label={t('journey.studio.travel')} />
|
<PanelHead label={t('journey.studio.travel')} />
|
||||||
<div className="st-panel-scroll">
|
<div className="st-panel-scroll">
|
||||||
<div className="st-section">
|
<Shelf label={t('journey.studio.singleFigures')}>
|
||||||
<div className="st-section-label">{t('journey.studio.singleFigures')}</div>
|
|
||||||
<div className="st-travel-grid">
|
<div className="st-travel-grid">
|
||||||
{BOOK_METRICS.map(metric => {
|
{BOOK_METRICS.map(metric => {
|
||||||
const el = singleEl(metric)
|
const el = singleEl(metric)
|
||||||
@@ -364,7 +354,7 @@ export function StudioTravelPanel({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Shelf>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
The switch is here, beside the maps and the figures it changes, and
|
The switch is here, beside the maps and the figures it changes, and
|
||||||
@@ -389,7 +379,7 @@ export function StudioTravelPanel({
|
|||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
badge={`${stops.length - excludedCount}/${stops.length}`}
|
badge={`${stops.length - excludedCount}/${stops.length}`}
|
||||||
>
|
>
|
||||||
<ul className="st-stops">
|
<ul className="st-route-stops">
|
||||||
{stops.map((stop, i) => {
|
{stops.map((stop, i) => {
|
||||||
const entryId = stop.entryId
|
const entryId = stop.entryId
|
||||||
const on = !stop.excluded
|
const on = !stop.excluded
|
||||||
@@ -406,14 +396,14 @@ export function StudioTravelPanel({
|
|||||||
*/
|
*/
|
||||||
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
|
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
|
||||||
return (
|
return (
|
||||||
<li key={entryId ?? `place-${i}`} className={`st-stop ${on ? '' : 'is-off'}`}>
|
<li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
|
||||||
{/*
|
{/*
|
||||||
One line: the day, then the name, then the switch. The
|
One line: the day, then the name, then the switch. The
|
||||||
name and the day were stacked, which doubled the height
|
name and the day were stacked, which doubled the height
|
||||||
of a list whose whole job is being read down.
|
of a list whose whole job is being read down.
|
||||||
*/}
|
*/}
|
||||||
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
|
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
|
||||||
<span className="st-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
<span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
||||||
{/*
|
{/*
|
||||||
The country only where there is more than one to tell
|
The country only where there is more than one to tell
|
||||||
apart. On a journey inside one country it is the same
|
apart. On a journey inside one country it is the same
|
||||||
@@ -443,8 +433,7 @@ export function StudioTravelPanel({
|
|||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="st-section">
|
<Shelf label={t('journey.studio.routeMap')}>
|
||||||
<div className="st-section-label">{t('journey.studio.routeMap')}</div>
|
|
||||||
{stats.points.length ? (
|
{stats.points.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="st-travel-grid">
|
<div className="st-travel-grid">
|
||||||
@@ -506,8 +495,8 @@ export function StudioTravelPanel({
|
|||||||
trip nobody wrote about would place an empty frame.
|
trip nobody wrote about would place an empty frame.
|
||||||
*/}
|
*/}
|
||||||
{tripsWithRoute.length > 1 && (
|
{tripsWithRoute.length > 1 && (
|
||||||
<div style={{ marginTop: 12 }}>
|
<div className="st-shelf-sub">
|
||||||
<div className="st-section-label">{t('journey.studio.mapPerTrip')}</div>
|
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
|
||||||
<div className="st-travel-grid">
|
<div className="st-travel-grid">
|
||||||
{tripsWithRoute.map(trip => {
|
{tripsWithRoute.map(trip => {
|
||||||
const el = mapEl('minimal', { tripId: trip.id })
|
const el = mapEl('minimal', { tripId: trip.id })
|
||||||
@@ -528,10 +517,9 @@ export function StudioTravelPanel({
|
|||||||
) : (
|
) : (
|
||||||
<p className="st-hint">{t('journey.studio.noRoute')}</p>
|
<p className="st-hint">{t('journey.studio.noRoute')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Shelf>
|
||||||
|
|
||||||
<div className="st-section">
|
<Shelf label={t('journey.studio.countries')}>
|
||||||
<div className="st-section-label">{t('journey.studio.countries')}</div>
|
|
||||||
{stats.countries.length ? (
|
{stats.countries.length ? (
|
||||||
<>
|
<>
|
||||||
{/*
|
{/*
|
||||||
@@ -574,10 +562,9 @@ export function StudioTravelPanel({
|
|||||||
) : (
|
) : (
|
||||||
<p className="st-hint">{t('journey.studio.noCountries')}</p>
|
<p className="st-hint">{t('journey.studio.noCountries')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Shelf>
|
||||||
|
|
||||||
<div className="st-section">
|
<Shelf label={t('journey.studio.marks')}>
|
||||||
<div className="st-section-label">{t('journey.studio.marks')}</div>
|
|
||||||
<div className="st-travel-grid">
|
<div className="st-travel-grid">
|
||||||
{marks.map(({ el, label }) => (
|
{marks.map(({ el, label }) => (
|
||||||
<Card key={el.id} label={label} onClick={() => place(el)}>
|
<Card key={el.id} label={label} onClick={() => place(el)}>
|
||||||
@@ -585,7 +572,7 @@ export function StudioTravelPanel({
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Shelf>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,9 +53,12 @@ export async function warmBookFaces(
|
|||||||
* Warms the faces of the book the Studio holds whenever the set of faces it
|
* 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.
|
* 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 {
|
export function useWarmBookFaces(): void {
|
||||||
const doc = useStudioStore((s) => s.doc);
|
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(() => {
|
useEffect(() => {
|
||||||
const current = useStudioStore.getState().doc;
|
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.
|
// 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,201 @@
|
|||||||
|
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 } 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 })}
|
||||||
|
aria-label={sh}
|
||||||
|
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,71 @@
|
|||||||
|
import type { BookElement, BookShapeElement, BookShapeId, BookTheme } from '@trek/shared';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { SHAPE_GROUPS } 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 })}
|
||||||
|
aria-label={sh}
|
||||||
|
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'
|
| '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 {
|
return {
|
||||||
id,
|
id,
|
||||||
pageWidthMm: w,
|
pageWidthMm: w,
|
||||||
@@ -56,14 +60,14 @@ function preset(id: PagePresetId, w: number, h: number, labelKey: string): PageP
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PAGE_PRESETS: Record<PagePresetId, PagePreset> = {
|
export const PAGE_PRESETS: Record<PagePresetId, PagePreset> = {
|
||||||
'square-210': preset('square-210', 210, 210, 'journey.studio.formatSquare21'),
|
'square-210': preset('square-210', 210, 210, { labelKey: 'journey.studio.formatSquare21' }),
|
||||||
'square-300': preset('square-300', 300, 300, 'journey.studio.formatSquare30'),
|
'square-300': preset('square-300', 300, 300, { labelKey: 'journey.studio.formatSquare30' }),
|
||||||
'a4-landscape': preset('a4-landscape', 297, 210, 'journey.studio.formatA4Landscape'),
|
'a4-landscape': preset('a4-landscape', 297, 210, { labelKey: 'journey.studio.formatA4Landscape' }),
|
||||||
'a4-portrait': preset('a4-portrait', 210, 297, 'journey.studio.formatA4Portrait'),
|
'a4-portrait': preset('a4-portrait', 210, 297, { labelKey: 'journey.studio.formatA4Portrait' }),
|
||||||
'a5-landscape': preset('a5-landscape', 210, 148, 'journey.studio.formatA5Landscape'),
|
'a5-landscape': preset('a5-landscape', 210, 148, { labelKey: 'journey.studio.formatA5Landscape' }),
|
||||||
// The dimensions here are only the starting point the fields open on; the
|
// The dimensions here are only the starting point the fields open on; the
|
||||||
// document keeps whatever the user types.
|
// 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 onStopKey = (index: number) => (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
const stop = paint.stops[index];
|
const stop = paint.stops[index];
|
||||||
if (!stop) return;
|
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();
|
e.preventDefault();
|
||||||
|
if (!canRemove) return;
|
||||||
onSelect(Math.max(0, index - 1));
|
onSelect(Math.max(0, index - 1));
|
||||||
onChange(removeStop(paint, index), true);
|
onChange(removeStop(paint, index), true);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
MAX_SPREAD_ELEMENTS,
|
MAX_SPREAD_ELEMENTS,
|
||||||
bookPhotoElementSchema,
|
bookPhotoElementSchema,
|
||||||
spreadPaint,
|
spreadPaint,
|
||||||
withSpreadPaint,
|
withSpreadPaintByHand,
|
||||||
type BookDocument,
|
type BookDocument,
|
||||||
type BookElement,
|
type BookElement,
|
||||||
type BookPageSetup,
|
type BookPageSetup,
|
||||||
@@ -77,20 +77,27 @@ export function withoutBackgroundPhoto(spread: BookSpread, page: BookPageSetup):
|
|||||||
const isCover = (spread: BookSpread) => spread.role === 'cover' || spread.role === 'back';
|
const isCover = (spread: BookSpread) => spread.role === 'cover' || spread.role === 'back';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every page given the background of page `from`: its paint and colour,
|
* Every page given the background of page `from`: its paint and colour, and
|
||||||
* nothing else. "Every page" is every page of its kind: an inside page
|
* whether it follows the theme, nothing else. "Every page" is every page of
|
||||||
* gives its background to the inside of the book, a cover to the covers,
|
* its kind: an inside page gives its background to the inside of the book, a
|
||||||
* since a cover is designed on its own and an inside page's paper is rarely
|
* cover to the covers, since a cover is designed on its own and an inside
|
||||||
* what the cover wants.
|
* 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 {
|
export function backgroundToAll(doc: BookDocument, from: number): BookDocument {
|
||||||
const source = doc.spreads[from];
|
const source = doc.spreads[from];
|
||||||
if (!source) return doc;
|
if (!source) return doc;
|
||||||
const paint = spreadPaint(source);
|
const paint = spreadPaint(source);
|
||||||
const kind = isCover(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 {
|
return {
|
||||||
...doc,
|
...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))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { FlipHorizontal2, Printer, RotateCcw, TriangleAlert } from 'lucide-react';
|
import { FlipHorizontal2, Printer, RotateCcw, TriangleAlert } from 'lucide-react';
|
||||||
|
|
||||||
import '../../../styles/studio-photo.css';
|
import '../../../styles/studio-photo.css';
|
||||||
|
import { photoEdgesInUse } from '../inspector/inspectorModel';
|
||||||
import { Line, NumField, Section, Switch } from '../StudioControls';
|
import { Line, NumField, Section, Switch } from '../StudioControls';
|
||||||
import { Swatches } from '../StudioSwatches';
|
import { Swatches } from '../StudioSwatches';
|
||||||
import { StudioSlider } from './StudioSlider';
|
import { StudioSlider } from './StudioSlider';
|
||||||
@@ -264,7 +265,7 @@ export function PhotoLookSections(props: RowProps) {
|
|||||||
const raster = t('journey.studio.photo.rasterHint');
|
const raster = t('journey.studio.photo.rasterHint');
|
||||||
return (
|
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
|
<EdgeRow
|
||||||
label={t('journey.studio.photo.border')}
|
label={t('journey.studio.photo.border')}
|
||||||
value={look.border}
|
value={look.border}
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ export function matchingEntries(source: JourneySource, query: string): JourneySo
|
|||||||
.some(v => v && v.toLowerCase().includes(q)))
|
.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.
|
* 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.
|
* on a day that has none, reads as the pictures having gone.
|
||||||
*/
|
*/
|
||||||
export function emptyKeyFor(source: JourneySource, filter: PhotoFilter, admitted: Photo[]): string {
|
export function emptyKeyFor(source: JourneySource, filter: PhotoFilter, admitted: Photo[]): string {
|
||||||
if (!source.photos.length) return 'journey.studio.noPhotos'
|
if (!source.photos.length) return EMPTY.none.emptyKey
|
||||||
if (admitted.length) return 'journey.studio.noMatches'
|
if (admitted.length) return EMPTY.unmatched.emptyKey
|
||||||
if (filter.kind === 'entry') return 'journey.studio.noEntryPhotos'
|
if (filter.kind === 'entry') return EMPTY.entry.emptyKey
|
||||||
if (filter.kind === 'loose') return 'journey.studio.noLoosePhotos'
|
if (filter.kind === 'loose') return EMPTY.loose.emptyKey
|
||||||
return 'journey.studio.noMatches'
|
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 { K, circle, polygon, r2, roundRect, scallop, star } from './shapePrimitives'
|
||||||
import { MORE_SHAPE_PATHS } from './shapes-more'
|
import { MORE_SHAPE_PATHS } from './shapes-more'
|
||||||
|
|
||||||
@@ -323,91 +323,10 @@ export function unitPath(path: string): string {
|
|||||||
*/
|
*/
|
||||||
export const HOLED_SHAPES = new Set<BookShapeId>(['gear', 'postmark', 'label-tab'])
|
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.
|
* 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
|
||||||
* Grouped the way you go looking for them — "I want an arrow", "I want
|
* asset listing reads too.
|
||||||
* 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[] = [
|
export const SHAPE_GROUPS = BOOK_SHAPE_GROUPS
|
||||||
{
|
export const FRAME_SHAPES = BOOK_FRAME_SHAPES
|
||||||
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,227 @@
|
|||||||
|
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"
|
||||||
|
/>
|
||||||
|
<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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { BookCoordFormat, BookElement, BookFrame, BookPageSetup } from '@trek/shared';
|
||||||
|
import { bookElementId as uid } from '@trek/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Content panel drops on a page: a photo, a piece of an entry still
|
||||||
|
* attached to it, a mood or weather mark, an entry's pros and cons.
|
||||||
|
*
|
||||||
|
* Each arrives centred on the page that is open, across both pages of a
|
||||||
|
* spread or on the one page of a cover, at a size that suits it. Pure, so the
|
||||||
|
* panel's buttons and the tests build the same elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Where a drop lands: the book's page, and whether the open page is a single one (a cover). */
|
||||||
|
export interface DropPlace {
|
||||||
|
page: BookPageSetup;
|
||||||
|
single: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The parts of an entry a text drop is bound to. */
|
||||||
|
type EntryTextSource = 'entry.title' | 'entry.story' | 'entry.location' | 'entry.date';
|
||||||
|
|
||||||
|
/** A frame of this size, centred on the open page or spread. */
|
||||||
|
export function centred({ page, single }: DropPlace, w: number, h: number): BookFrame {
|
||||||
|
const width = single ? page.pageWidth : page.pageWidth * 2;
|
||||||
|
return { x: (width - w) / 2, y: (page.pageHeight - h) / 2, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A photo, a little over half the short side of the page, in a 4:3 frame. */
|
||||||
|
export function photoDrop(place: DropPlace, photoId: number): BookElement {
|
||||||
|
const side = Math.min(place.page.pageWidth, place.page.pageHeight) * 0.55;
|
||||||
|
return {
|
||||||
|
id: uid('p'),
|
||||||
|
kind: 'photo',
|
||||||
|
frame: centred(place, 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. `width` exists because a line of
|
||||||
|
* coordinates arriving inside a full-column selection rectangle looks like a
|
||||||
|
* bug; only the short marks pass it. `value` is the fact behind formatted
|
||||||
|
* words (the ISO date, the point), so a book can re-read its own text.
|
||||||
|
*/
|
||||||
|
export function textDrop(
|
||||||
|
place: DropPlace,
|
||||||
|
text: string,
|
||||||
|
style: { size: number; weight: 400 | 700 },
|
||||||
|
binding: { entryId: number; source: EntryTextSource; format?: BookCoordFormat; value?: string },
|
||||||
|
width?: number
|
||||||
|
): BookElement {
|
||||||
|
const { size, weight } = style;
|
||||||
|
const w = width ?? place.page.pageWidth - 32;
|
||||||
|
return {
|
||||||
|
id: uid('t'),
|
||||||
|
kind: 'text',
|
||||||
|
frame: centred(place, w, size * 0.4 + 10),
|
||||||
|
rotation: 0,
|
||||||
|
opacity: 1,
|
||||||
|
locked: false,
|
||||||
|
text,
|
||||||
|
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: binding.source,
|
||||||
|
entryId: binding.entryId,
|
||||||
|
...(binding.format ? { format: binding.format } : {}),
|
||||||
|
...(binding.value ? { value: binding.value } : {}),
|
||||||
|
},
|
||||||
|
overridden: false,
|
||||||
|
} as BookElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What every mark and list starts from: upright, opaque, in the book's ink with one warm accent. */
|
||||||
|
const MARK_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.
|
||||||
|
*/
|
||||||
|
export function markDrop(place: DropPlace, variant: 'mood' | 'weather', code: string, label: string): BookElement {
|
||||||
|
return {
|
||||||
|
...MARK_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: centred(place, place.page.pageWidth * 0.22, place.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, tall enough for its longer column. */
|
||||||
|
export function prosConsDrop(
|
||||||
|
place: DropPlace,
|
||||||
|
items: { pros: string[]; cons: string[] },
|
||||||
|
labels: { pro: string; con: string }
|
||||||
|
): BookElement {
|
||||||
|
const rows = Math.max(items.pros.length, items.cons.length, 1);
|
||||||
|
return {
|
||||||
|
...MARK_BASE,
|
||||||
|
id: uid('li'),
|
||||||
|
kind: 'list',
|
||||||
|
// What ListView needs for `rows` lines at its largest line size, so the element arrives filled.
|
||||||
|
frame: centred(place, place.page.pageWidth * 0.72, Math.min(place.page.pageHeight * 0.6, 6 * (0.86 + 1.7 * rows))),
|
||||||
|
items: [
|
||||||
|
...items.pros.map((text) => ({ text, tone: 'pro' as const })),
|
||||||
|
...items.cons.map((text) => ({ text, tone: 'con' as const })),
|
||||||
|
],
|
||||||
|
layout: 'columns',
|
||||||
|
showMarks: true,
|
||||||
|
proLabel: labels.pro,
|
||||||
|
conLabel: labels.con,
|
||||||
|
} as BookElement;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Compass, Files, ImageIcon, Layers, LayoutTemplate, Palette, Shapes, type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sections of the rail, in the order a book is made.
|
||||||
|
*
|
||||||
|
* The first five put things on a page, read from top to bottom the way the
|
||||||
|
* work goes: which page, the journey's own photos and words, an arrangement
|
||||||
|
* for them, things to add (type, shapes, lines, frames, icons, stickers), and
|
||||||
|
* the journey's figures. The last two are about the book and the page as a
|
||||||
|
* whole, its look and what lies in front of what, and the rail sets them
|
||||||
|
* apart with a rule.
|
||||||
|
*
|
||||||
|
* Stickers used to be a section of their own beside Elements. They are a kind
|
||||||
|
* of thing to add like a shape or an icon, so they are now a tab of Elements,
|
||||||
|
* and the rail holds one place to add things instead of two.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RailSection = 'pages' | 'content' | 'templates' | 'elements' | 'travel' | 'theme' | 'layers';
|
||||||
|
|
||||||
|
interface RailEntry {
|
||||||
|
id: RailSection;
|
||||||
|
icon: LucideIcon;
|
||||||
|
labelKey: string;
|
||||||
|
/** The first section of the second group, which the rail sets apart. */
|
||||||
|
startsGroup?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RAIL_SECTIONS: readonly RailEntry[] = [
|
||||||
|
{ id: 'pages', icon: Files, labelKey: 'journey.studio.pages' },
|
||||||
|
{ id: 'content', icon: ImageIcon, labelKey: 'journey.studio.content' },
|
||||||
|
{ id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' },
|
||||||
|
{ id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' },
|
||||||
|
{ id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' },
|
||||||
|
{ id: 'theme', icon: Palette, labelKey: 'studio.theme.panel', startsGroup: true },
|
||||||
|
{ id: 'layers', icon: Layers, labelKey: 'journey.studio.layers' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The ids the rail's tabs and the panel they open share. */
|
||||||
|
export const RAIL_IDS = {
|
||||||
|
tab: (id: RailSection) => `st-rail-tab-${id}`,
|
||||||
|
panel: 'st-rail-panel',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useRef, useState, type DragEvent } from 'react';
|
||||||
|
|
||||||
|
import { useToast } from '../../shared/Toast';
|
||||||
|
import type { PhotoFilter } from '../photoFilter';
|
||||||
|
import type { StudioUploader } from '../studioUpload';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The way pictures get into the Content panel: a file picked, a file dropped
|
||||||
|
* anywhere on the panel, or a photo provider's picker (#2271).
|
||||||
|
*
|
||||||
|
* 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. 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.
|
||||||
|
*/
|
||||||
|
export function useContentUpload({
|
||||||
|
filter,
|
||||||
|
showArrived,
|
||||||
|
onUpload,
|
||||||
|
onBrowseProvider,
|
||||||
|
enabled,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
filter: PhotoFilter;
|
||||||
|
/** Shows the pictures that just landed, and lets go of the search that might hide them. */
|
||||||
|
showArrived: (photoIds: number[]) => void;
|
||||||
|
onUpload: StudioUploader;
|
||||||
|
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>;
|
||||||
|
/** False for a viewer, or on a tab that shows no photos: nothing is taken. */
|
||||||
|
enabled: boolean;
|
||||||
|
t: (k: string, params?: Record<string, string | number>) => string;
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
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);
|
||||||
|
const entryId = filter.kind === 'entry' ? filter.id : null;
|
||||||
|
|
||||||
|
const send = async (files: File[]) => {
|
||||||
|
if (sending.current || !files.length) return;
|
||||||
|
sending.current = true;
|
||||||
|
setUpload({ done: 0, total: files.length });
|
||||||
|
try {
|
||||||
|
const sent = await onUpload(files, entryId, (p) => setUpload({ done: p.done, total: p.total }));
|
||||||
|
if (sent.photoIds.length) {
|
||||||
|
showArrived(sent.photoIds);
|
||||||
|
toast.success(t('journey.photosUploaded', { count: sent.photoIds.length }));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sending.current = false;
|
||||||
|
setUpload(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** From a photo provider: into the same place an upload goes, and shown the same way. */
|
||||||
|
const pickFromProvider = async (provider: string) => {
|
||||||
|
if (!onBrowseProvider) return;
|
||||||
|
const photoIds = await onBrowseProvider(provider, entryId);
|
||||||
|
if (photoIds.length) showArrived(photoIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The whole scrolling area takes a drop, not just the dashed cell: a drag
|
||||||
|
* from the desktop arrives with the pointer wherever it arrives. Only a file
|
||||||
|
* drag is claimed; the panel's own photo drags carry another type and go to
|
||||||
|
* the sheet.
|
||||||
|
*/
|
||||||
|
const dropZone = enabled
|
||||||
|
? {
|
||||||
|
onDragOver: (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!e.dataTransfer.types.includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
setDropOver(true);
|
||||||
|
},
|
||||||
|
onDragLeave: (e: 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: DragEvent<HTMLDivElement>) => {
|
||||||
|
const files = Array.from(e.dataTransfer.files ?? []);
|
||||||
|
if (!files.length) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDropOver(false);
|
||||||
|
void send(files);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return { fileInput, upload, dropOver, dropZone, send, pickFromProvider };
|
||||||
|
}
|
||||||
@@ -120,8 +120,9 @@ export function pickFaceRules(rules: readonly FaceRule[], uses: FaceUses): FaceR
|
|||||||
const slanted = ofFamily.filter((r) => r.italic === (italicText === 'true'));
|
const slanted = ofFamily.filter((r) => r.italic === (italicText === 'true'));
|
||||||
const styled = slanted.length ? slanted : ofFamily.filter((r) => !r.italic);
|
const styled = slanted.length ? slanted : ofFamily.filter((r) => !r.italic);
|
||||||
if (!styled.length) continue;
|
if (!styled.length) continue;
|
||||||
const nearest = styled.reduce((best, r) =>
|
const nearest = styled.reduce(
|
||||||
Math.abs(r.weight - weight) < Math.abs(best.weight - weight) ? r : best
|
(best, r) => (Math.abs(r.weight - weight) < Math.abs(best.weight - weight) ? r : best),
|
||||||
|
styled[0]
|
||||||
).weight;
|
).weight;
|
||||||
for (const rule of styled) {
|
for (const rule of styled) {
|
||||||
if (rule.weight === nearest && coversAny(rule.unicodeRange, chars)) picked.set(rule.url, rule);
|
if (rule.weight === nearest && coversAny(rule.unicodeRange, chars)) picked.set(rule.url, rule);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { BookElement, BookSpread } from '@trek/shared';
|
|||||||
import { bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
|
import { bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { dropOn, layerLabel, layerRows, rowIds } from './studioLayerRows';
|
import { dropOn, layerKeyAction, layerLabel, layerRows, rowIds } from './studioLayerRows';
|
||||||
import { shortcutKeys } from './studioShortcut';
|
import { shortcutKeys } from './studioShortcut';
|
||||||
|
|
||||||
/** The value, or a failed test when it is missing. */
|
/** The value, or a failed test when it is missing. */
|
||||||
@@ -59,6 +59,42 @@ describe('dropOn', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('layerKeyAction', () => {
|
||||||
|
const open = layerRows(spread, new Set());
|
||||||
|
const ltr = { collapsed: new Set<string>(), rtl: false, additive: false };
|
||||||
|
|
||||||
|
it('walks the rows with Up, Down, Home and End, and stops at the ends', () => {
|
||||||
|
expect(layerKeyAction('ArrowDown', 0, open, ltr)).toEqual({ type: 'focus', index: 1 });
|
||||||
|
expect(layerKeyAction('ArrowDown', 4, open, ltr)).toEqual({ type: 'focus', index: 4 });
|
||||||
|
expect(layerKeyAction('ArrowUp', 0, open, ltr)).toEqual({ type: 'focus', index: 0 });
|
||||||
|
expect(layerKeyAction('End', 1, open, ltr)).toEqual({ type: 'focus', index: 4 });
|
||||||
|
expect(layerKeyAction('Home', 3, open, ltr)).toEqual({ type: 'focus', index: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens and closes a group, and steps between it and its members, mirrored right to left', () => {
|
||||||
|
const folded = layerRows(spread, new Set(['g']));
|
||||||
|
const closed = { ...ltr, collapsed: new Set(['g']) };
|
||||||
|
expect(layerKeyAction('ArrowLeft', 1, open, ltr)).toEqual({ type: 'collapse', groupId: 'g' });
|
||||||
|
expect(layerKeyAction('ArrowRight', 1, folded, closed)).toEqual({ type: 'expand', groupId: 'g' });
|
||||||
|
expect(layerKeyAction('ArrowRight', 1, open, ltr)).toEqual({ type: 'focus', index: 2 });
|
||||||
|
expect(layerKeyAction('ArrowLeft', 3, open, ltr)).toEqual({ type: 'focus', index: 1 });
|
||||||
|
expect(layerKeyAction('ArrowLeft', 1, folded, closed)).toBeNull();
|
||||||
|
expect(layerKeyAction('ArrowRight', 0, open, ltr)).toBeNull();
|
||||||
|
expect(layerKeyAction('ArrowLeft', 0, open, ltr)).toBeNull();
|
||||||
|
const rtl = { ...ltr, rtl: true };
|
||||||
|
expect(layerKeyAction('ArrowRight', 1, open, rtl)).toEqual({ type: 'collapse', groupId: 'g' });
|
||||||
|
expect(layerKeyAction('ArrowLeft', 1, open, rtl)).toEqual({ type: 'focus', index: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects with Enter or Space, renames with F2, and leaves every other key to the page', () => {
|
||||||
|
expect(layerKeyAction('Enter', 2, open, ltr)).toEqual({ type: 'choose', additive: false });
|
||||||
|
expect(layerKeyAction(' ', 2, open, { ...ltr, additive: true })).toEqual({ type: 'choose', additive: true });
|
||||||
|
expect(layerKeyAction('F2', 0, open, ltr)).toEqual({ type: 'rename' });
|
||||||
|
expect(layerKeyAction('Delete', 0, open, ltr)).toBeNull();
|
||||||
|
expect(layerKeyAction('Enter', 9, open, ltr)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('layerLabel', () => {
|
describe('layerLabel', () => {
|
||||||
it('uses the name, then a text’s first words, then the kind', () => {
|
it('uses the name, then a text’s first words, then the kind', () => {
|
||||||
expect(layerLabel(shape('a', { name: 'Sun' }), 'Shape')).toBe('Sun');
|
expect(layerLabel(shape('a', { name: 'Sun' }), 'Shape')).toBe('Sun');
|
||||||
|
|||||||
@@ -91,3 +91,61 @@ export function selectionTitle(spread: BookSpread | undefined, selection: string
|
|||||||
}
|
}
|
||||||
return t('journey.studio.multiple');
|
return t('journey.studio.multiple');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a key pressed on a row of the layers tree does. */
|
||||||
|
export type LayerKeyAction =
|
||||||
|
| { type: 'focus'; index: number }
|
||||||
|
| { type: 'expand'; groupId: string }
|
||||||
|
| { type: 'collapse'; groupId: string }
|
||||||
|
| { type: 'choose'; additive: boolean }
|
||||||
|
| { type: 'rename' };
|
||||||
|
|
||||||
|
/** Up and Down along the rows, Home and End to the ends; the tree stops at either end. */
|
||||||
|
function alongTree(key: string, index: number, count: number): number | null {
|
||||||
|
if (key === 'Home') return 0;
|
||||||
|
if (key === 'End') return count - 1;
|
||||||
|
if (key === 'ArrowDown') return Math.min(index + 1, count - 1);
|
||||||
|
if (key === 'ArrowUp') return Math.max(index - 1, 0);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Into a group: a closed one opens, an open one hands the focus to its first member. */
|
||||||
|
function inward(row: LayerRow, index: number, collapsed: ReadonlySet<string>): LayerKeyAction | null {
|
||||||
|
if (row.type !== 'group') return null;
|
||||||
|
return collapsed.has(row.groupId) ? { type: 'expand', groupId: row.groupId } : { type: 'focus', index: index + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Out of a group: an open one closes, a member hands the focus to its group's row. */
|
||||||
|
function outward(rows: readonly LayerRow[], index: number, collapsed: ReadonlySet<string>): LayerKeyAction | null {
|
||||||
|
const row = rows[index];
|
||||||
|
if (row?.type === 'group') return collapsed.has(row.groupId) ? null : { type: 'collapse', groupId: row.groupId };
|
||||||
|
if (!row?.inGroup) return null;
|
||||||
|
// A member's group is the nearest group row above it: members are listed right under their group.
|
||||||
|
for (let i = index - 1; i >= 0; i--) if (rows[i]?.type === 'group') return { type: 'focus', index: i };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tree's keyboard, as a tree view has it: Up and Down move along the
|
||||||
|
* rows, Home and End to the ends; Right opens a closed group and steps into
|
||||||
|
* an open one, Left closes an open group and steps from a member up to its
|
||||||
|
* group, the two mirrored in a language read right to left; Enter or Space
|
||||||
|
* selects the row as a click does, `additive` (Shift, Ctrl or Cmd held)
|
||||||
|
* adding it; F2 renames it. Null for a key the tree leaves to the page.
|
||||||
|
*/
|
||||||
|
export function layerKeyAction(
|
||||||
|
key: string,
|
||||||
|
index: number,
|
||||||
|
rows: readonly LayerRow[],
|
||||||
|
opts: { collapsed: ReadonlySet<string>; rtl: boolean; additive: boolean }
|
||||||
|
): LayerKeyAction | null {
|
||||||
|
const row = rows[index];
|
||||||
|
if (!row) return null;
|
||||||
|
if (key === 'Enter' || key === ' ') return { type: 'choose', additive: opts.additive };
|
||||||
|
if (key === 'F2') return { type: 'rename' };
|
||||||
|
const along = alongTree(key, index, rows.length);
|
||||||
|
if (along !== null) return { type: 'focus', index: along };
|
||||||
|
if (key === (opts.rtl ? 'ArrowLeft' : 'ArrowRight')) return inward(row, index, opts.collapsed);
|
||||||
|
if (key === (opts.rtl ? 'ArrowRight' : 'ArrowLeft')) return outward(rows, index, opts.collapsed);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { BookLineElement, BookPhotoElement, BookShapeElement } from '@trek/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixed sets of looks Studio offers in more than one place, with the words
|
||||||
|
* for each: the six ways a picture is framed, offered when an empty frame is
|
||||||
|
* added and when a photo is restyled, and the three strokes a shape's outline
|
||||||
|
* and a drawn line share.
|
||||||
|
*
|
||||||
|
* Written once here so the Elements panel and the inspector cannot drift into
|
||||||
|
* two lists, and so every key is named in full where the checker of
|
||||||
|
* translation keys can read it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FrameStyle = NonNullable<BookPhotoElement['frameStyle']>;
|
||||||
|
|
||||||
|
export const FRAME_STYLE_OPTIONS: readonly { value: FrameStyle; labelKey: string }[] = [
|
||||||
|
{ value: 'none', labelKey: 'journey.studio.plainFrame' },
|
||||||
|
{ value: 'polaroid', labelKey: 'journey.studio.polaroidFrame' },
|
||||||
|
{ value: 'white', labelKey: 'journey.studio.whiteFrame' },
|
||||||
|
{ value: 'shadow', labelKey: 'journey.studio.shadowFrame' },
|
||||||
|
{ value: 'film', labelKey: 'journey.studio.filmFrame' },
|
||||||
|
{ value: 'tape', labelKey: 'journey.studio.tapeFrame' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A shape's outline and a drawn line take the same three strokes. */
|
||||||
|
type StrokeStyle = NonNullable<BookShapeElement['strokeStyle']> & NonNullable<BookLineElement['dash']>;
|
||||||
|
|
||||||
|
export const STROKE_STYLE_OPTIONS: readonly { value: StrokeStyle; labelKey: string }[] = [
|
||||||
|
{ value: 'solid', labelKey: 'journey.studio.strokeSolid' },
|
||||||
|
{ value: 'dashed', labelKey: 'journey.studio.strokeDashed' },
|
||||||
|
{ value: 'dotted', labelKey: 'journey.studio.strokeDotted' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { rovingTarget, runsRtl } from './studioRoving';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FE-STUDIO-ROVING-001 to FE-STUDIO-ROVING-004: the arrow keys through a strip
|
||||||
|
* of tabs, the rail and the tab rows inside the panels.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ACROSS = { vertical: false, rtl: false };
|
||||||
|
const DOWN = { vertical: true, rtl: false };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.documentElement.removeAttribute('dir');
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rovingTarget', () => {
|
||||||
|
it('FE-STUDIO-ROVING-001: moves along a row with the arrows, wrapping at both ends', () => {
|
||||||
|
expect(rovingTarget('ArrowRight', 0, 3, ACROSS)).toBe(1);
|
||||||
|
expect(rovingTarget('ArrowRight', 2, 3, ACROSS)).toBe(0);
|
||||||
|
expect(rovingTarget('ArrowLeft', 0, 3, ACROSS)).toBe(2);
|
||||||
|
expect(rovingTarget('ArrowLeft', 2, 3, ACROSS)).toBe(1);
|
||||||
|
// Up and down mean nothing to a row.
|
||||||
|
expect(rovingTarget('ArrowDown', 0, 3, ACROSS)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STUDIO-ROVING-002: follows the reading direction across, and goes up and down a column', () => {
|
||||||
|
const rtl = { vertical: false, rtl: true };
|
||||||
|
expect(rovingTarget('ArrowLeft', 0, 3, rtl)).toBe(1);
|
||||||
|
expect(rovingTarget('ArrowRight', 0, 3, rtl)).toBe(2);
|
||||||
|
expect(rovingTarget('ArrowDown', 1, 4, DOWN)).toBe(2);
|
||||||
|
expect(rovingTarget('ArrowUp', 0, 4, DOWN)).toBe(3);
|
||||||
|
expect(rovingTarget('ArrowRight', 1, 4, DOWN)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STUDIO-ROVING-003: jumps to the ends, and takes no key on an empty strip', () => {
|
||||||
|
expect(rovingTarget('Home', 2, 5, DOWN)).toBe(0);
|
||||||
|
expect(rovingTarget('End', 0, 5, ACROSS)).toBe(4);
|
||||||
|
expect(rovingTarget('Enter', 1, 5, ACROSS)).toBeNull();
|
||||||
|
expect(rovingTarget('ArrowDown', 0, 0, DOWN)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runsRtl', () => {
|
||||||
|
it('FE-STUDIO-ROVING-004: reads the direction of the page around the element', () => {
|
||||||
|
const tab = document.createElement('button');
|
||||||
|
document.body.append(tab);
|
||||||
|
expect(runsRtl(tab)).toBe(false);
|
||||||
|
document.documentElement.setAttribute('dir', 'rtl');
|
||||||
|
expect(runsRtl(tab)).toBe(true);
|
||||||
|
const ltr = document.createElement('div');
|
||||||
|
ltr.setAttribute('dir', 'ltr');
|
||||||
|
ltr.append(tab);
|
||||||
|
document.body.append(ltr);
|
||||||
|
expect(runsRtl(tab)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Arrow keys through a strip of tabs: the rail down the side of Studio and the
|
||||||
|
* tab rows inside its panels.
|
||||||
|
*
|
||||||
|
* One tab of a strip is in the Tab order, the chosen one, and the arrows move
|
||||||
|
* the focus along the strip from there, wrapping at both ends; Home and End
|
||||||
|
* jump to the ends. A strip read across follows the reading direction, so in
|
||||||
|
* Arabic the left arrow moves on to the next tab. The focus moves, the choice
|
||||||
|
* does not: a panel opens on Enter or Space, as a button does, because the
|
||||||
|
* panels behind these tabs are photo grids and libraries, and arrowing past one
|
||||||
|
* should not draw it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RovingStrip {
|
||||||
|
/** Read top to bottom (the rail) rather than across (a tab row). */
|
||||||
|
vertical: boolean;
|
||||||
|
/** The strip runs right to left. Only read for a strip that runs across. */
|
||||||
|
rtl: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tab a key moves the focus to, or null for a key the strip leaves alone. */
|
||||||
|
export function rovingTarget(key: string, index: number, count: number, strip: RovingStrip): number | null {
|
||||||
|
if (count <= 0) return null;
|
||||||
|
const last = count - 1;
|
||||||
|
if (key === 'Home') return 0;
|
||||||
|
if (key === 'End') return last;
|
||||||
|
const back = strip.vertical ? 'ArrowUp' : strip.rtl ? 'ArrowRight' : 'ArrowLeft';
|
||||||
|
const ahead = strip.vertical ? 'ArrowDown' : strip.rtl ? 'ArrowLeft' : 'ArrowRight';
|
||||||
|
if (key === back) return index <= 0 ? last : index - 1;
|
||||||
|
if (key === ahead) return index >= last ? 0 : index + 1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the element sits in right to left text, read from the page rather than from the locale. */
|
||||||
|
export function runsRtl(el: Element): boolean {
|
||||||
|
return el.closest('[dir]')?.getAttribute('dir') === 'rtl';
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { BookTravelContext, JourneyStats } from '@trek/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the book engine's travel factories read, as the Studio knows it: the
|
||||||
|
* journey's figures, its recorded way and the words in the reader's
|
||||||
|
* language. The Travel panel builds its tiles with it, and the Templates
|
||||||
|
* panel lays a template's map with it, so both draw the same journey.
|
||||||
|
*
|
||||||
|
* The day word is the engine's template: `t` leaves the `{day}` in it
|
||||||
|
* untouched, and `bookDayText` puts the number there.
|
||||||
|
*/
|
||||||
|
export function studioTravelContext(
|
||||||
|
stats: JourneyStats,
|
||||||
|
path: [number, number][][],
|
||||||
|
locale: string,
|
||||||
|
t: (k: string) => string
|
||||||
|
): BookTravelContext {
|
||||||
|
return {
|
||||||
|
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'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { BookDocument } from '@trek/shared';
|
||||||
|
import { bookPageSetupSchema, decoratedTemplatesFor, normalizeBookDocument } from '@trek/shared';
|
||||||
|
|
||||||
|
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||||
|
import { useStudioStore } from '../../../store/studioStore';
|
||||||
|
import { StudioTemplatesPanel } from './StudioTemplatesPanel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Templates panel: the decorated templates of the open page's kind, each
|
||||||
|
* a button named after its template and drawn as the page it lays, a style
|
||||||
|
* filter, and a click that lays the page out in one undo step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = (k: string) => k;
|
||||||
|
const page = bookPageSetupSchema.parse({});
|
||||||
|
const source = {
|
||||||
|
title: 'Azores',
|
||||||
|
subtitle: 'Nine islands in the Atlantic',
|
||||||
|
entries: [{ id: 4, title: 'Sintra', story: 'Up through the fog.', location: 'Serra de Sintra', date: '2026-05-14' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const book = (title = 'Portugal'): BookDocument =>
|
||||||
|
normalizeBookDocument({
|
||||||
|
version: 1,
|
||||||
|
title,
|
||||||
|
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||||
|
spreads: [
|
||||||
|
{
|
||||||
|
id: 'sp-cover',
|
||||||
|
role: 'cover',
|
||||||
|
background: null,
|
||||||
|
entryId: null,
|
||||||
|
parked: [],
|
||||||
|
elements: [{ id: 'ph', kind: 'photo', frame: { x: 0, y: 0, w: 50, h: 50 }, photoId: 7 }],
|
||||||
|
},
|
||||||
|
{ id: 'sp-in', role: 'inner', background: null, entryId: 4, parked: [], elements: [] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/* jsdom lays nothing out; the cards measure their row to scale the page drawing into it. */
|
||||||
|
const size = Object.getOwnPropertyDescriptors(HTMLElement.prototype);
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 220 });
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 600 });
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
for (const key of ['offsetWidth', 'offsetHeight'] as const) {
|
||||||
|
const was = size[key];
|
||||||
|
if (was) Object.defineProperty(HTMLElement.prototype, key, was);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useStudioStore.getState().load(book());
|
||||||
|
useStudioStore.setState({ readOnly: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
const panel = (onOpenContent = vi.fn()) =>
|
||||||
|
render(
|
||||||
|
<StudioTemplatesPanel
|
||||||
|
page={page}
|
||||||
|
pxPerMm={96 / 25.4}
|
||||||
|
t={t}
|
||||||
|
locale="en"
|
||||||
|
source={source}
|
||||||
|
stats={null}
|
||||||
|
path={[]}
|
||||||
|
onOpenContent={onOpenContent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const spreadOf = (i: number) => useStudioStore.getState().doc?.spreads[i];
|
||||||
|
|
||||||
|
describe('the Templates panel', () => {
|
||||||
|
it('offers the cover templates on the cover, each a named button drawn with the page photo and the book title', () => {
|
||||||
|
panel();
|
||||||
|
for (const tpl of decoratedTemplatesFor('single')) {
|
||||||
|
expect(screen.getByRole('button', { name: tpl.labelKey })).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(screen.queryByRole('button', { name: 'studio.templates.name.polaroidStack' })).toBeNull();
|
||||||
|
const card = screen.getByRole('button', { name: 'studio.templates.name.minimalSerifClassic' });
|
||||||
|
expect(card.textContent).toContain('Portugal');
|
||||||
|
expect(card.querySelector('img')?.getAttribute('src')).toContain('/api/photos/7/');
|
||||||
|
expect(screen.getByText('studio.templates.decorated')).toBeTruthy();
|
||||||
|
expect(screen.getByText('journey.studio.builtInLayouts')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows the set to one archetype from the style chips', () => {
|
||||||
|
panel();
|
||||||
|
const chips = screen.getByRole('radiogroup', { name: 'studio.templates.style' });
|
||||||
|
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(8);
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }).getAttribute('aria-checked')
|
||||||
|
).toBe('true');
|
||||||
|
const names = decoratedTemplatesFor('single', 'postcard').map((tpl) => tpl.labelKey);
|
||||||
|
for (const name of names) expect(screen.getByRole('button', { name })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('button', { name: 'studio.templates.name.gridBadgeCircle' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays the page out on a click, as one undo step, keeping its photo', () => {
|
||||||
|
panel();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'studio.templates.name.postcardHouses' }));
|
||||||
|
const cover = spreadOf(0);
|
||||||
|
expect(cover?.elements.some((e) => e.kind === 'sticker' && e.stickerId === 'rowHouses')).toBe(true);
|
||||||
|
expect(cover?.elements.find((e) => e.kind === 'photo')).toMatchObject({ photoId: 7 });
|
||||||
|
expect(cover?.elements.some((e) => e.kind === 'text' && e.text === 'Portugal')).toBe(true);
|
||||||
|
expect(useStudioStore.getState().past).toHaveLength(1);
|
||||||
|
useStudioStore.getState().undo();
|
||||||
|
expect(spreadOf(0)?.elements.map((e) => e.id)).toEqual(['ph']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets a cover in the journey's subtitle, bound to it, and in the journey's title when the book has none", () => {
|
||||||
|
panel();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'studio.templates.name.minimalSerifClassic' }));
|
||||||
|
const texts = (spreadOf(0)?.elements ?? []).flatMap((e) => (e.kind === 'text' ? [e] : []));
|
||||||
|
expect(texts.map((e) => e.text)).toEqual(['Portugal', 'Nine islands in the Atlantic', '2026']);
|
||||||
|
expect(texts[1]?.binding).toEqual({ source: 'journey.subtitle' });
|
||||||
|
|
||||||
|
act(() => useStudioStore.getState().load(book('')));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'studio.templates.name.minimalSerifClassic' }));
|
||||||
|
const untitled = (spreadOf(0)?.elements ?? []).flatMap((e) => (e.kind === 'text' ? [e.text] : []));
|
||||||
|
expect(untitled[0]).toBe('Azores');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the spread templates on a double page, sets it in its entry and opens the photos for empty frames', () => {
|
||||||
|
useStudioStore.getState().setActiveSpread(1);
|
||||||
|
const onOpenContent = vi.fn();
|
||||||
|
panel(onOpenContent);
|
||||||
|
for (const tpl of decoratedTemplatesFor('inner')) {
|
||||||
|
expect(screen.getByRole('button', { name: tpl.labelKey })).toBeTruthy();
|
||||||
|
}
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'studio.templates.name.storyQuote' }));
|
||||||
|
const inner = spreadOf(1);
|
||||||
|
const title = inner?.elements.find((e) => e.kind === 'text' && e.text === 'Sintra');
|
||||||
|
expect(title?.kind === 'text' && title.binding).toEqual({ source: 'entry.title', entryId: 4 });
|
||||||
|
expect(onOpenContent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays a built-in layout out from the same panel', () => {
|
||||||
|
useStudioStore.getState().setActiveSpread(1);
|
||||||
|
panel();
|
||||||
|
fireEvent.click(screen.getByText('journey.studio.tpl.grid4'));
|
||||||
|
expect(spreadOf(1)?.elements.filter((e) => e.kind === 'photo')).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves what is locked where it is, as the assistant does, and lays out the rest', () => {
|
||||||
|
const locked = normalizeBookDocument({
|
||||||
|
...book(),
|
||||||
|
spreads: [
|
||||||
|
book().spreads[0],
|
||||||
|
{
|
||||||
|
id: 'sp-in',
|
||||||
|
role: 'inner',
|
||||||
|
background: null,
|
||||||
|
entryId: 4,
|
||||||
|
parked: [],
|
||||||
|
elements: [
|
||||||
|
{ id: 'panel', kind: 'shape', frame: { x: 0, y: 150, w: 420, h: 60 }, locked: true },
|
||||||
|
{ id: 'free', kind: 'photo', frame: { x: 230, y: 20, w: 60, h: 60 }, photoId: 8 },
|
||||||
|
{ id: 'kept', kind: 'photo', frame: { x: 10, y: 20, w: 60, h: 60 }, photoId: 9, locked: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
useStudioStore.getState().load(locked);
|
||||||
|
useStudioStore.getState().setActiveSpread(1);
|
||||||
|
panel();
|
||||||
|
fireEvent.click(screen.getByText('journey.studio.tpl.grid4'));
|
||||||
|
const after = spreadOf(1)?.elements ?? [];
|
||||||
|
expect(after[0]).toEqual(locked.spreads[1]?.elements[0]);
|
||||||
|
expect(after.find((e) => e.id === 'kept')).toEqual(locked.spreads[1]?.elements[2]);
|
||||||
|
const poured = after.filter((e) => e.kind === 'photo' && !e.locked);
|
||||||
|
expect(poured).toHaveLength(4);
|
||||||
|
expect(poured.map((e) => (e.kind === 'photo' ? e.photoId : null))).toContain(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import type { BookDecoratedTemplate, BookPageSetup, BookSpread, BookTemplate, JourneyStats } from '@trek/shared';
|
||||||
|
import {
|
||||||
|
applyBookTemplate as applyTemplate,
|
||||||
|
BOOK_COVER_TEMPLATES as COVER_TEMPLATES,
|
||||||
|
layoutAroundLocks,
|
||||||
|
BOOK_TEMPLATES as TEMPLATES,
|
||||||
|
} from '@trek/shared';
|
||||||
|
import { memo, useDeferredValue, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useElementSize } from '../../../hooks/useElementSize';
|
||||||
|
import { useStudioStore } from '../../../store/studioStore';
|
||||||
|
import '../../../styles/studio-templates.css';
|
||||||
|
import { SpreadThumb } from '../SpreadThumb';
|
||||||
|
import { Choice } from '../StudioControls';
|
||||||
|
import { PanelHead } from '../StudioPanelHead';
|
||||||
|
import { StudioSavedLayouts } from '../StudioSavedLayouts';
|
||||||
|
import {
|
||||||
|
ARCHETYPE_FILTERS,
|
||||||
|
emptyFrames,
|
||||||
|
layTemplate,
|
||||||
|
pageKind,
|
||||||
|
previewIds,
|
||||||
|
studioTemplateWords,
|
||||||
|
templatesForSpread,
|
||||||
|
templateTravel,
|
||||||
|
type ArchetypeFilter,
|
||||||
|
type TemplateContext,
|
||||||
|
type TemplateSource,
|
||||||
|
} from './templatesModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Templates section of the rail: the decorated templates first, then the
|
||||||
|
* book's own layouts and the built-in wireframes.
|
||||||
|
*
|
||||||
|
* A decorated template is a whole designed page, and its card is that page as
|
||||||
|
* the click will lay it: this page's photos and words poured into it, in the
|
||||||
|
* book's theme, drawn by the page renderer at a small scale. Covers are drawn
|
||||||
|
* two to a row so the set can be compared at a glance; a double spread takes
|
||||||
|
* the full width. A row of style chips narrows the set to one archetype.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The smallest a preview is drawn, and the button's border and padding the row's width still includes. */
|
||||||
|
const MIN_PREVIEW_W = 96;
|
||||||
|
const THUMB_CHROME = (2 + 3) * 2;
|
||||||
|
/** The gap between two cover cards in a row, as studio-templates.css sets it. */
|
||||||
|
const PAIR_GAP = 8;
|
||||||
|
|
||||||
|
type Translate = (k: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
/** How wide a card draws its page, from the measured row: the full row, or half of it for a cover. */
|
||||||
|
function previewWidths(row: number, single: boolean): { full: number; card: number } {
|
||||||
|
const full = Math.max(MIN_PREVIEW_W, row - THUMB_CHROME);
|
||||||
|
return { full, card: single ? Math.max(MIN_PREVIEW_W / 2, (row - PAIR_GAP) / 2 - THUMB_CHROME) : full };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecoratedCardProps {
|
||||||
|
tpl: BookDecoratedTemplate;
|
||||||
|
spread: BookSpread;
|
||||||
|
page: BookPageSetup;
|
||||||
|
ctx: TemplateContext;
|
||||||
|
width: number;
|
||||||
|
pxPerMm: number;
|
||||||
|
label: string;
|
||||||
|
onPick: (tpl: BookDecoratedTemplate) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One decorated template, drawn as the page it makes of the page that is open. */
|
||||||
|
const DecoratedCard = memo(function DecoratedCard({
|
||||||
|
tpl,
|
||||||
|
spread,
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
width,
|
||||||
|
pxPerMm,
|
||||||
|
label,
|
||||||
|
onPick,
|
||||||
|
}: DecoratedCardProps) {
|
||||||
|
const preview = useMemo(
|
||||||
|
() => layTemplate(spread, tpl, page, ctx, previewIds(`tpl-${tpl.id}`)),
|
||||||
|
[spread, tpl, page, ctx]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button type="button" className="st-thumb st-tpl-card" title={label} aria-label={label} onClick={() => onPick(tpl)}>
|
||||||
|
<span aria-hidden className="st-tpl-art">
|
||||||
|
<SpreadThumb
|
||||||
|
spread={preview}
|
||||||
|
page={page}
|
||||||
|
width={width}
|
||||||
|
pxPerMm={pxPerMm}
|
||||||
|
fold={tpl.kind === 'inner'}
|
||||||
|
className="st-thumb-sheet"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="st-thumb-label st-tpl-label">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The built-in layouts, drawn as the frames they lay out: the wireframes Studio has always had. */
|
||||||
|
function BuiltInLayouts({
|
||||||
|
spread,
|
||||||
|
page,
|
||||||
|
pxPerMm,
|
||||||
|
width,
|
||||||
|
t,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
spread: BookSpread;
|
||||||
|
page: BookPageSetup;
|
||||||
|
pxPerMm: number;
|
||||||
|
width: number;
|
||||||
|
t: Translate;
|
||||||
|
onPick: (tpl: BookTemplate) => void;
|
||||||
|
}) {
|
||||||
|
const single = spread.role !== 'inner';
|
||||||
|
// A cover card is one page wide, not two: scaled as a spread it would draw at half size.
|
||||||
|
const scale = width / (page.pageWidth * (single ? 1 : 2) * pxPerMm);
|
||||||
|
const templates = single ? COVER_TEMPLATES : TEMPLATES;
|
||||||
|
return (
|
||||||
|
<div className="st-thumbs">
|
||||||
|
{templates.map((tpl) => (
|
||||||
|
<button type="button" key={tpl.id} className="st-thumb" onClick={() => onPick(tpl)}>
|
||||||
|
{/* A page is laid out from its top left in either reading direction. */}
|
||||||
|
<div dir="ltr" className="st-thumb-sheet" style={{ width, height: page.pageHeight * pxPerMm * scale }}>
|
||||||
|
{tpl.build(page).map((slot, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`st-tpl-slot is-${slot.kind}`}
|
||||||
|
style={{
|
||||||
|
insetInlineStart: 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={{ insetInlineStart: page.pageWidth * pxPerMm * scale }} />}
|
||||||
|
</div>
|
||||||
|
<span className="st-thumb-label">{t(tpl.labelKey)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudioTemplatesPanel({
|
||||||
|
page,
|
||||||
|
pxPerMm,
|
||||||
|
t,
|
||||||
|
locale,
|
||||||
|
source,
|
||||||
|
stats,
|
||||||
|
path,
|
||||||
|
onOpenContent,
|
||||||
|
}: {
|
||||||
|
page: BookPageSetup;
|
||||||
|
pxPerMm: number;
|
||||||
|
t: Translate;
|
||||||
|
locale: string;
|
||||||
|
source: TemplateSource;
|
||||||
|
stats: JourneyStats | null;
|
||||||
|
path: [number, number][][];
|
||||||
|
/** Opens the panel of the journey's photos, for frames a layout left empty. */
|
||||||
|
onOpenContent: () => void;
|
||||||
|
}) {
|
||||||
|
const doc = useStudioStore((s) => s.doc);
|
||||||
|
const active = useStudioStore((s) => s.activeSpread);
|
||||||
|
const commit = useStudioStore((s) => s.commit);
|
||||||
|
const select = useStudioStore((s) => s.select);
|
||||||
|
const [filter, setFilter] = useState<ArchetypeFilter>('all');
|
||||||
|
const list = useElementSize<HTMLDivElement>();
|
||||||
|
// The cards redraw from the page as it is; deferred, so typing on the page never waits on them.
|
||||||
|
const spread = useDeferredValue(doc?.spreads[active]);
|
||||||
|
const theme = doc?.theme;
|
||||||
|
const title = doc?.title ?? '';
|
||||||
|
|
||||||
|
const ctx = useMemo<TemplateContext | null>(() => {
|
||||||
|
if (!spread) return null;
|
||||||
|
const words = studioTemplateWords(title, spread, source, locale);
|
||||||
|
return { theme, words, travel: templateTravel(stats, path, locale, t), locale };
|
||||||
|
}, [spread, theme, title, source, stats, path, locale, t]);
|
||||||
|
|
||||||
|
if (!doc || !spread || !ctx) return null;
|
||||||
|
const single = spread.role !== 'inner';
|
||||||
|
const templates = templatesForSpread(spread, filter);
|
||||||
|
const { full, card: cardWidth } = previewWidths(list.width, single);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One undo step: the page laid out, nothing selected, and the photos at hand
|
||||||
|
* when frames are left empty. It works on the page as it is now, not as the
|
||||||
|
* deferred cards last drew it: a click right after moving to another page
|
||||||
|
* lays nothing when the layout is for the other kind of page.
|
||||||
|
*/
|
||||||
|
const lay = (make: (sp: BookSpread) => BookSpread | null) => {
|
||||||
|
const done: { page: BookSpread | null } = { page: null };
|
||||||
|
commit((d) => {
|
||||||
|
const now = d.spreads[active];
|
||||||
|
const next = now ? make(now) : null;
|
||||||
|
done.page = next;
|
||||||
|
return next ? { ...d, spreads: d.spreads.map((sp, i) => (i === active ? next : sp)) } : d;
|
||||||
|
});
|
||||||
|
if (!done.page) return;
|
||||||
|
select([]);
|
||||||
|
if (emptyFrames(done.page) > 0) onOpenContent();
|
||||||
|
};
|
||||||
|
const pickDecorated = (tpl: BookDecoratedTemplate) =>
|
||||||
|
lay((sp) =>
|
||||||
|
pageKind(sp) === tpl.kind
|
||||||
|
? layTemplate(sp, tpl, page, { ...ctx, words: studioTemplateWords(title, sp, source, locale) })
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
// A built-in layout arranges what is not locked, as the assistant's does; a decorated one keeps locks itself.
|
||||||
|
const pickBuiltIn = (tpl: BookTemplate) =>
|
||||||
|
lay((sp) =>
|
||||||
|
pageKind(sp) === (tpl.role ?? 'inner') ? layoutAroundLocks(sp, (free) => applyTemplate(free, tpl, page)) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PanelHead label={t('journey.studio.templates')} count={templates.length} />
|
||||||
|
<div className="st-panel-scroll">
|
||||||
|
<div className="st-section st-tpl-section">
|
||||||
|
<div className="st-section-label">{t('studio.templates.decorated')}</div>
|
||||||
|
<Choice<ArchetypeFilter>
|
||||||
|
value={filter}
|
||||||
|
ariaLabel={t('studio.templates.style')}
|
||||||
|
onPick={setFilter}
|
||||||
|
options={ARCHETYPE_FILTERS.map((a) => ({
|
||||||
|
value: a,
|
||||||
|
label: a === 'all' ? t('studio.templates.all') : t(`studio.templates.archetype.${a}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div className={`st-tpl-grid ${single ? 'is-pages' : 'is-spreads'}`} ref={list.ref}>
|
||||||
|
{templates.map((tpl) => (
|
||||||
|
<DecoratedCard
|
||||||
|
key={tpl.id}
|
||||||
|
tpl={tpl}
|
||||||
|
spread={spread}
|
||||||
|
page={page}
|
||||||
|
ctx={ctx}
|
||||||
|
width={cardWidth}
|
||||||
|
pxPerMm={pxPerMm}
|
||||||
|
label={t(tpl.labelKey)}
|
||||||
|
onPick={pickDecorated}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="st-hint">{t('studio.templates.hint')}</p>
|
||||||
|
</div>
|
||||||
|
<StudioSavedLayouts page={page} width={full} pxPerMm={pxPerMm} t={t} />
|
||||||
|
<BuiltInLayouts spread={spread} page={page} pxPerMm={pxPerMm} width={full} t={t} onPick={pickBuiltIn} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { BookSpread } from '@trek/shared';
|
||||||
|
import { bookSpreadSchema, journeyStatsSchema } from '@trek/shared';
|
||||||
|
|
||||||
|
import { pageKind, studioTemplateWords, templateTravel, templatesForSpread } from './templatesModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Templates panel lays a page out with: which templates a page
|
||||||
|
* takes, the words their empty roles take (the same the assistant's layout
|
||||||
|
* gives), and the journey's figures for a template's map.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = (k: string) => `[${k}]`;
|
||||||
|
const spread = (role: BookSpread['role'], entryId: number | null = null) =>
|
||||||
|
bookSpreadSchema.parse({ id: `sp-${role}`, role, entryId, elements: [] });
|
||||||
|
const source = {
|
||||||
|
title: 'Azores',
|
||||||
|
subtitle: 'Nine islands in the Atlantic',
|
||||||
|
entries: [{ id: 4, title: 'Sintra', story: 'Up through the fog.', location: 'Serra de Sintra', date: '2026-05-14' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('the templates a page takes', () => {
|
||||||
|
it('takes the covers on every single page and the spreads on a double one', () => {
|
||||||
|
for (const role of ['cover', 'first', 'last', 'back'] as const) expect(pageKind(spread(role))).toBe('single');
|
||||||
|
expect(pageKind(spread('inner'))).toBe('inner');
|
||||||
|
expect(templatesForSpread(spread('back'), 'all').every((tpl) => tpl.kind === 'single')).toBe(true);
|
||||||
|
expect(templatesForSpread(spread('inner'), 'postcard').every((tpl) => tpl.archetype === 'postcard')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the words of a template', () => {
|
||||||
|
it("sets a cover in the book's title, the journey's when the book has none, and the journey's subtitle bound to it", () => {
|
||||||
|
const own = studioTemplateWords(' Portugal ', spread('cover'), source, 'en');
|
||||||
|
expect(own.title).toEqual({ text: 'Portugal' });
|
||||||
|
expect(own.subtitle).toEqual({ text: 'Nine islands in the Atlantic', binding: { source: 'journey.subtitle' } });
|
||||||
|
expect(own.date).toEqual({ text: '2026' });
|
||||||
|
expect(studioTemplateWords('', spread('cover'), source, 'en').title).toEqual({ text: 'Azores' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a cover without a title when neither the book nor the journey has one, rather than a sample word', () => {
|
||||||
|
const words = studioTemplateWords('', spread('cover'), { ...source, title: '', subtitle: null }, 'en');
|
||||||
|
expect(words.title).toBeUndefined();
|
||||||
|
expect(words.subtitle).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets a page made from an entry in that entry, bound to it', () => {
|
||||||
|
const words = studioTemplateWords('Portugal', spread('inner', 4), source, 'en');
|
||||||
|
expect(words.title).toEqual({ text: 'Sintra', binding: { source: 'entry.title', entryId: 4 } });
|
||||||
|
expect(words.date?.binding).toEqual({ source: 'entry.date', entryId: 4, value: '2026-05-14' });
|
||||||
|
expect(studioTemplateWords('Portugal', spread('inner'), source, 'en')).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the journey of a template map', () => {
|
||||||
|
it('has none while the figures load, and the travel words of the reader once they are in', () => {
|
||||||
|
expect(templateTravel(null, [], 'en', t)).toBeNull();
|
||||||
|
const stats = journeyStatsSchema.parse({
|
||||||
|
journeyId: 1,
|
||||||
|
distance: 1200,
|
||||||
|
days: 3,
|
||||||
|
steps: 2,
|
||||||
|
photos: 0,
|
||||||
|
places: 0,
|
||||||
|
furthest: 900,
|
||||||
|
countries: [],
|
||||||
|
points: [],
|
||||||
|
start: '2026-05-12',
|
||||||
|
end: '2026-05-14',
|
||||||
|
});
|
||||||
|
const travel = templateTravel(stats, [[[38.7, -9.1]]], 'de', t);
|
||||||
|
expect(travel).toMatchObject({ locale: 'de', stats, path: [[[38.7, -9.1]]], entries: [] });
|
||||||
|
expect(travel?.labels).toEqual({
|
||||||
|
day: '[journey.studio.day]',
|
||||||
|
stations: '[journey.studio.stations]',
|
||||||
|
summary: '[journey.studio.summary]',
|
||||||
|
countries: '[journey.studio.countries]',
|
||||||
|
distance: '[journey.studio.metric.distance]',
|
||||||
|
pros: '[journey.editor.pros]',
|
||||||
|
cons: '[journey.editor.cons]',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type {
|
||||||
|
BookCoverArchetype,
|
||||||
|
BookDecoratedTemplate,
|
||||||
|
BookDocument,
|
||||||
|
BookPageSetup,
|
||||||
|
BookSpread,
|
||||||
|
BookTemplateKind,
|
||||||
|
BookTemplateWords,
|
||||||
|
BookTravelContext,
|
||||||
|
JourneyStats,
|
||||||
|
} from '@trek/shared';
|
||||||
|
import { BOOK_COVER_ARCHETYPES, applyDecoratedTemplate, bookTemplateWords, decoratedTemplatesFor } from '@trek/shared';
|
||||||
|
|
||||||
|
import { studioTravelContext } from '../studioTravelContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Templates panel lays out and draws: the decorated templates a page
|
||||||
|
* can take, the words and the figures they are filled with, and the page one
|
||||||
|
* template makes. The card and the click run the same function, so a card
|
||||||
|
* shows the page the click gives, with this page's own photos and words in it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ArchetypeFilter = BookCoverArchetype | 'all';
|
||||||
|
|
||||||
|
/** The panel's style filter: every template, or those of one archetype. */
|
||||||
|
export const ARCHETYPE_FILTERS: readonly ArchetypeFilter[] = ['all', ...BOOK_COVER_ARCHETYPES];
|
||||||
|
|
||||||
|
/** Which templates a page takes: a single page (the cover, the first, the last, the back) or a double spread. */
|
||||||
|
export function pageKind(spread: BookSpread): BookTemplateKind {
|
||||||
|
return spread.role === 'inner' ? 'inner' : 'single';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The decorated templates for a page: covers for a single page, spreads for a double one. */
|
||||||
|
export function templatesForSpread(spread: BookSpread, filter: ArchetypeFilter): BookDecoratedTemplate[] {
|
||||||
|
return decoratedTemplatesFor(pageKind(spread), filter === 'all' ? null : filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the Studio knows of the journey that a template takes words and a map from. */
|
||||||
|
export interface TemplateSource {
|
||||||
|
/** The journey's title, for a cover of a book without its own, and its subtitle. */
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
entries: readonly {
|
||||||
|
id: number;
|
||||||
|
title: string | null;
|
||||||
|
story: string | null;
|
||||||
|
location: string | null;
|
||||||
|
date: string | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything a page is laid out on a template with. */
|
||||||
|
export interface TemplateContext {
|
||||||
|
theme: BookDocument['theme'];
|
||||||
|
words: BookTemplateWords;
|
||||||
|
travel: BookTravelContext | null;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The words a template's empty roles take, as the assistant's layout gives
|
||||||
|
* them (template-op.ts in shared): on a single page the book's title, or the
|
||||||
|
* journey's when the book has none, the journey's subtitle and the year; on
|
||||||
|
* a page made from a journal entry that entry's title, place, date and story.
|
||||||
|
*/
|
||||||
|
export function studioTemplateWords(
|
||||||
|
bookTitle: string,
|
||||||
|
spread: BookSpread,
|
||||||
|
source: TemplateSource,
|
||||||
|
locale: string
|
||||||
|
): BookTemplateWords {
|
||||||
|
return bookTemplateWords(spread, {
|
||||||
|
bookTitle: bookTitle.trim() || (source.title ?? ''),
|
||||||
|
subtitle: source.subtitle ?? null,
|
||||||
|
locale,
|
||||||
|
entries: source.entries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The journey's figures and way, for a template's map; null while they are loading or failed. */
|
||||||
|
export function templateTravel(
|
||||||
|
stats: JourneyStats | null,
|
||||||
|
path: [number, number][][],
|
||||||
|
locale: string,
|
||||||
|
t: (k: string) => string
|
||||||
|
): BookTravelContext | null {
|
||||||
|
return stats ? studioTravelContext(stats, path, locale, t) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page laid out on a template: what a card draws and what a click commits. */
|
||||||
|
export function layTemplate(
|
||||||
|
spread: BookSpread,
|
||||||
|
tpl: BookDecoratedTemplate,
|
||||||
|
page: BookPageSetup,
|
||||||
|
ctx: TemplateContext,
|
||||||
|
newId?: (prefix: string) => string
|
||||||
|
): BookSpread {
|
||||||
|
return applyDecoratedTemplate(spread, tpl, page, {
|
||||||
|
theme: ctx.theme ?? null,
|
||||||
|
words: ctx.words,
|
||||||
|
travel: ctx.travel,
|
||||||
|
locale: ctx.locale,
|
||||||
|
...(newId ? { newId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ids for a card's drawing that stay the same from one render to the next, so its keys do too. */
|
||||||
|
export function previewIds(prefix: string): (kind: string) => string {
|
||||||
|
let n = 0;
|
||||||
|
return (kind) => `${prefix}-${kind}${(n += 1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many frames a laid page is left waiting for a picture in. */
|
||||||
|
export function emptyFrames(spread: BookSpread): number {
|
||||||
|
return spread.elements.filter((e) => e.kind === 'photo' && e.photoId == null).length;
|
||||||
|
}
|
||||||
@@ -14,11 +14,11 @@ import { Check, ImageIcon, RotateCcw } from 'lucide-react';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import '../../../styles/studio-theme.css';
|
import '../../../styles/studio-theme.css';
|
||||||
import { useToast } from '../../shared/Toast';
|
import { useToast } from '../../shared/Toast';
|
||||||
import { fontStack } from '../bookFonts';
|
|
||||||
import { Choice, Line, Picks, Section, Switch } from '../StudioControls';
|
import { Choice, Line, Picks, Section, Switch } from '../StudioControls';
|
||||||
import { StudioFontPicker } from '../StudioFontPicker';
|
import { StudioFontPicker } from '../StudioFontPicker';
|
||||||
import { PanelHead } from '../StudioPanelHead';
|
import { PanelHead, Shelf } from '../StudioPanelHead';
|
||||||
import type { StudioT } from '../StudioSidebar';
|
import type { StudioT } from '../StudioSidebar';
|
||||||
|
import { ThemeSheet } from './ThemeSheet';
|
||||||
import { useStudioTheme } from './useStudioTheme';
|
import { useStudioTheme } from './useStudioTheme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +46,6 @@ function ThemeCard({
|
|||||||
t: StudioT;
|
t: StudioT;
|
||||||
}) {
|
}) {
|
||||||
const display = useMemo(() => bookThemeOf(preset.id).fonts.display, [preset.id]);
|
const display = useMemo(() => bookThemeOf(preset.id).fonts.display, [preset.id]);
|
||||||
const { palette } = preset;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -55,23 +54,7 @@ function ThemeCard({
|
|||||||
onClick={onPick}
|
onClick={onPick}
|
||||||
title={t(preset.descrKey)}
|
title={t(preset.descrKey)}
|
||||||
>
|
>
|
||||||
<span className="st-theme-sheet" style={{ background: palette.paper }}>
|
<ThemeSheet palette={preset.palette} display={display} />
|
||||||
<span
|
|
||||||
className="st-theme-word"
|
|
||||||
style={{
|
|
||||||
fontFamily: fontStack(display.font),
|
|
||||||
color: palette.ink,
|
|
||||||
fontStyle: display.italic ? 'italic' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Aa
|
|
||||||
</span>
|
|
||||||
<span className="st-theme-dots" aria-hidden>
|
|
||||||
{[palette.accent, palette.accent2, palette.accent3].map((c, i) => (
|
|
||||||
<span key={i} style={{ background: c }} />
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="st-theme-name">
|
<span className="st-theme-name">
|
||||||
<span>{t(preset.labelKey)}</span>
|
<span>{t(preset.labelKey)}</span>
|
||||||
{inUse && (
|
{inUse && (
|
||||||
@@ -165,26 +148,27 @@ export function StudioThemePanel({ t }: { t: StudioT }) {
|
|||||||
<div className="st-panel-scroll st-theme">
|
<div className="st-panel-scroll st-theme">
|
||||||
<p className="st-hint">{t('studio.theme.intro')}</p>
|
<p className="st-hint">{t('studio.theme.intro')}</p>
|
||||||
|
|
||||||
<div className="st-section-label">{t('studio.theme.themes')}</div>
|
<Shelf label={t('studio.theme.themes')}>
|
||||||
<div className="st-theme-grid">
|
<div className="st-theme-grid">
|
||||||
{BOOK_THEME_PRESETS.map((preset) => (
|
{BOOK_THEME_PRESETS.map((preset) => (
|
||||||
<ThemeCard
|
<ThemeCard
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
preset={preset}
|
preset={preset}
|
||||||
on={draft.id === preset.id}
|
on={draft.id === preset.id}
|
||||||
inUse={theme.current?.id === preset.id}
|
inUse={theme.current?.id === preset.id}
|
||||||
onPick={() => theme.pick(preset.id)}
|
onPick={() => theme.pick(preset.id)}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{theme.edited && (
|
{theme.edited && (
|
||||||
<button type="button" className="st-theme-reset" onClick={theme.reset}>
|
<button type="button" className="st-theme-reset" onClick={theme.reset}>
|
||||||
<RotateCcw size={13} />
|
<RotateCcw size={13} />
|
||||||
{t('studio.theme.reset')}
|
{t('studio.theme.reset')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</Shelf>
|
||||||
|
|
||||||
<Section label={t('studio.theme.palette')}>
|
<Section label={t('studio.theme.palette')}>
|
||||||
<div className="st-theme-palette">
|
<div className="st-theme-palette">
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { BookPalette, BookThemeFont } from '@trek/shared';
|
||||||
|
|
||||||
|
import '../../../styles/studio-theme.css';
|
||||||
|
import { fontStack } from '../bookFonts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A theme as a little page: its paper, "Aa" in its display face and ink, and
|
||||||
|
* its three accents. The Theme panel's cards and the inspector's summary of the
|
||||||
|
* book's theme draw it alike, so the theme a book has looks like the card it
|
||||||
|
* was picked from.
|
||||||
|
*
|
||||||
|
* The colours are the theme's own, set inline from the data: the sheet shows
|
||||||
|
* what the theme offers, which no token of the app's could.
|
||||||
|
*/
|
||||||
|
export function ThemeSheet({ palette, display }: { palette: BookPalette; display: BookThemeFont }) {
|
||||||
|
return (
|
||||||
|
<span className="st-theme-sheet" style={{ background: palette.paper }} aria-hidden>
|
||||||
|
<span
|
||||||
|
className="st-theme-word"
|
||||||
|
style={{
|
||||||
|
fontFamily: fontStack(display.font),
|
||||||
|
color: palette.ink,
|
||||||
|
fontStyle: display.italic ? 'italic' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</span>
|
||||||
|
<span className="st-theme-dots">
|
||||||
|
{[palette.accent, palette.accent2, palette.accent3].map((c, i) => (
|
||||||
|
<span key={i} style={{ background: c }} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
BOOK_THEME_IDS,
|
BOOK_THEME_IDS,
|
||||||
bookPaletteFromPhotos,
|
bookPaletteFromPhotos,
|
||||||
bookThemeOf,
|
bookThemeOf,
|
||||||
|
compareCodeUnits,
|
||||||
setBookTheme,
|
setBookTheme,
|
||||||
type BookFontSlot,
|
type BookFontSlot,
|
||||||
type BookPaletteKey,
|
type BookPaletteKey,
|
||||||
@@ -42,6 +43,15 @@ export function themeRequest(draft: BookTheme, spreadIds: string[] | undefined,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A theme as text, its keys in one order at every depth, so the same theme read twice compares equal. */
|
||||||
|
function themeKey(theme: BookTheme | undefined): string {
|
||||||
|
if (!theme) return '';
|
||||||
|
return JSON.stringify(theme, (_key, value: unknown) => {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
||||||
|
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => compareCodeUnits(a, b)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a draft differs from the built-in theme it is named after. */
|
/** Whether a draft differs from the built-in theme it is named after. */
|
||||||
export function isEdited(draft: BookTheme): boolean {
|
export function isEdited(draft: BookTheme): boolean {
|
||||||
if (!draft.id) return false;
|
if (!draft.id) return false;
|
||||||
@@ -60,10 +70,13 @@ export function useStudioTheme() {
|
|||||||
const [reading, setReading] = useState(false);
|
const [reading, setReading] = useState(false);
|
||||||
|
|
||||||
// The book's theme changed under the panel (applied, undone, another editor): the draft follows it,
|
// The book's theme changed under the panel (applied, undone, another editor): the draft follows it,
|
||||||
// set while rendering rather than in an effect, so the panel never paints the old draft first.
|
// set while rendering rather than in an effect, so the panel never paints the old draft first. Compared
|
||||||
const [seen, setSeen] = useState(current);
|
// by what it says, not by identity: a save from elsewhere reads the same theme in anew, and must not
|
||||||
if (seen !== current) {
|
// throw away colours being tried here.
|
||||||
setSeen(current);
|
const currentKey = useMemo(() => themeKey(current), [current]);
|
||||||
|
const [seen, setSeen] = useState(currentKey);
|
||||||
|
if (seen !== currentKey) {
|
||||||
|
setSeen(currentKey);
|
||||||
if (current) setDraft(current);
|
if (current) setDraft(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ import { useStudioStore } from '../../store/studioStore';
|
|||||||
* Every shortcut here is one a design tool has taught people already, and none
|
* Every shortcut here is one a design tool has taught people already, and none
|
||||||
* of them takes a key away from the browser while somebody is typing: in a
|
* of them takes a key away from the browser while somebody is typing: in a
|
||||||
* field, a text area or a text being edited on the page, the keys are the
|
* field, a text area or a text being edited on the page, the keys are the
|
||||||
* field's. Undo and redo are the Studio page's own (useJourneyStudio.ts), and
|
* field's. Nor from a control that takes the key itself: a gradient stop,
|
||||||
* every action below is one undo step there.
|
* the angle dial or a row of the layers tree prevents its default, and the
|
||||||
|
* assistant's window stops its keys from reaching the page, so Delete on a
|
||||||
|
* focused stop removes the stop, not the shape. Undo and redo are the Studio
|
||||||
|
* page's own (useJourneyStudio.ts), and every action below is one undo step
|
||||||
|
* there.
|
||||||
*
|
*
|
||||||
* - Delete or Backspace: remove what is selected and not locked.
|
* - Delete or Backspace: remove what is selected and not locked.
|
||||||
* - Ctrl+C, Ctrl+V: copy and paste elements, onto any page (#2316).
|
* - Ctrl+C, Ctrl+V: copy and paste elements, onto any page (#2316).
|
||||||
@@ -134,7 +138,7 @@ export function useStudioKeys(opts: { spreadIndex: number; editing: boolean }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (editing || isTypingTarget(e.target)) return;
|
if (editing || e.defaultPrevented || isTypingTarget(e.target)) return;
|
||||||
const store = useStudioStore.getState();
|
const store = useStudioStore.getState();
|
||||||
// Read when the key comes, not when the effect ran: a nudge changes the
|
// Read when the key comes, not when the effect ran: a nudge changes the
|
||||||
// page, and an effect that re-ran on it would close the gesture each step.
|
// page, and an effect that re-ran on it would close the gesture each step.
|
||||||
@@ -164,13 +168,15 @@ export function useStudioKeys(opts: { spreadIndex: number; editing: boolean }) {
|
|||||||
if (ARROWS[e.key]) stopNudge();
|
if (ARROWS[e.key]) stopNudge();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capture, so a step out of a group is decided before the page's Escape clears the selection.
|
// On the document as the key bubbles: after every control and the assistant's window had it (React
|
||||||
window.addEventListener('keydown', onKey, true);
|
// listens below the document), and before the page's Escape on the window, so a step out of a group
|
||||||
|
// is decided before the page clears the selection.
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
window.addEventListener('keyup', onKeyUp, true);
|
window.addEventListener('keyup', onKeyUp, true);
|
||||||
window.addEventListener('blur', stopNudge);
|
window.addEventListener('blur', stopNudge);
|
||||||
return () => {
|
return () => {
|
||||||
stopNudge();
|
stopNudge();
|
||||||
window.removeEventListener('keydown', onKey, true);
|
document.removeEventListener('keydown', onKey);
|
||||||
window.removeEventListener('keyup', onKeyUp, true);
|
window.removeEventListener('keyup', onKeyUp, true);
|
||||||
window.removeEventListener('blur', stopNudge);
|
window.removeEventListener('blur', stopNudge);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,14 +64,15 @@ export async function findRoundHikes(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const state = await searchRoundLoops(request.start, request.targetM, (spots) => askLoop(request, spots, signal), {
|
const state = await searchRoundLoops(request.start, request.targetM, (spots) => askLoop(request, spots, signal), {
|
||||||
signal,
|
signal,
|
||||||
onStep: ({ found, asked }) => report({ phase: 'searching', candidates: found.map(candidateOf), asked }),
|
onStep: ({ found, asked }) =>
|
||||||
|
report({ phase: 'searching', candidates: found.map((loop, i) => candidateOf(loop, i)), asked }),
|
||||||
});
|
});
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
if (state.answered === 0) {
|
if (state.answered === 0) {
|
||||||
report({ phase: 'failed', candidates: [], asked: state.asked });
|
report({ phase: 'failed', candidates: [], asked: state.asked });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const found = state.found.map(candidateOf);
|
const found = state.found.map((loop, i) => candidateOf(loop, i));
|
||||||
let candidates = [...found];
|
let candidates = [...found];
|
||||||
report({ phase: 'done', candidates, asked: state.asked });
|
report({ phase: 'done', candidates, asked: state.asked });
|
||||||
for (const round of found) {
|
for (const round of found) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
import type { TourListItem } from '@trek/shared';
|
import type { TourListItem } from '@trek/shared';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { OnlineOnlyError } from '../../repo/onlineOnly';
|
||||||
import type { TourPlannerController } from './planner/useTourPlanner';
|
import type { TourPlannerController } from './planner/useTourPlanner';
|
||||||
import { useRecordedTrack } from './useRecordedTrack';
|
import { useRecordedTrack } from './useRecordedTrack';
|
||||||
|
|
||||||
@@ -87,4 +88,21 @@ describe('useRecordedTrack', () => {
|
|||||||
expect(controller.returnToNeutral).not.toHaveBeenCalled();
|
expect(controller.returnToNeutral).not.toHaveBeenCalled();
|
||||||
expect(onRestored).not.toHaveBeenCalled();
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('says it needs a connection when it is offline or behind an edit of the tour still waiting', async () => {
|
||||||
|
repo.restoreRecorded.mockRejectedValueOnce(new OnlineOnlyError());
|
||||||
|
const controller = planner(7);
|
||||||
|
const onRestored = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useRecordedTrack({ tripId: 1, planner: controller, tours: [replaced], canEdit: true, onRestored })
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('tours.recorded.offline');
|
||||||
|
expect(controller.returnToNeutral).not.toHaveBeenCalled();
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TourListItem } from '@trek/shared';
|
import type { TourListItem } from '@trek/shared';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { OnlineOnlyError } from '../../repo/onlineOnly';
|
||||||
import { tourRepo } from '../../repo/tourRepo';
|
import { tourRepo } from '../../repo/tourRepo';
|
||||||
import { useToast } from '../shared/Toast';
|
import { useToast } from '../shared/Toast';
|
||||||
import type { TourPlannerController } from './planner/useTourPlanner';
|
import type { TourPlannerController } from './planner/useTourPlanner';
|
||||||
@@ -36,8 +37,9 @@ export function useRecordedTrack({ tripId, planner, tours, canEdit, onRestored }
|
|||||||
returnToNeutral();
|
returnToNeutral();
|
||||||
await onRestored(result.tour);
|
await onRestored(result.tour);
|
||||||
toast.success(t('tours.recorded.restored'));
|
toast.success(t('tours.recorded.restored'));
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
toast.error(t('tours.recorded.failed'));
|
// Offline, or behind an edit of the tour still waiting to be sent: said as such, not as a failure.
|
||||||
|
toast.error(t(err instanceof OnlineOnlyError ? 'tours.recorded.offline' : 'tours.recorded.failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setRestoring(false);
|
setRestoring(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode, useEffect, useLayoutEffect, useRef } from 'react'
|
||||||
|
|
||||||
interface MDropdownPanelProps {
|
interface MDropdownPanelProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -12,9 +12,36 @@ interface MDropdownPanelProps {
|
|||||||
* Floating popover panel (user menu, filter dropdowns): heavy glass, r20,
|
* Floating popover panel (user menu, filter dropdowns): heavy glass, r20,
|
||||||
* closed by a transparent full-screen backdrop — no dim, unlike sheets.
|
* closed by a transparent full-screen backdrop — no dim, unlike sheets.
|
||||||
* Layered above the scrolling content but below the bottom nav (z-40) and
|
* Layered above the scrolling content but below the bottom nav (z-40) and
|
||||||
* sheets (z-60), matching the design's stacking order.
|
* sheets (z-60), matching the design's stacking order. Opened, it takes the
|
||||||
|
* focus onto its first button and Escape closes it; closed, the focus goes
|
||||||
|
* back to the button it was opened from, unless a pick moved it on.
|
||||||
*/
|
*/
|
||||||
export default function MDropdownPanel({ open, onClose, className = '', children }: MDropdownPanelProps) {
|
export default function MDropdownPanel({ open, onClose, className = '', children }: MDropdownPanelProps) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const onCloseRef = useRef(onClose)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onCloseRef.current = onClose
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||||
|
panelRef.current?.querySelector<HTMLElement>('button:not(:disabled)')?.focus({ preventScroll: true })
|
||||||
|
// Captured and stopped, so the key closes the panel and nothing under it.
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return
|
||||||
|
e.stopPropagation()
|
||||||
|
onCloseRef.current()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey, true)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey, true)
|
||||||
|
// The focused button left with the panel: the focus fell back to the page, and goes to the opener.
|
||||||
|
const lost = document.activeElement === null || document.activeElement === document.body
|
||||||
|
if (lost && opener?.isConnected) opener.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,6 +50,7 @@ export default function MDropdownPanel({ open, onClose, className = '', children
|
|||||||
no role of its own; the panel's own controls are the keyboard path. */}
|
no role of its own; the panel's own controls are the keyboard path. */}
|
||||||
<div className="fixed inset-0 z-[35]" role="presentation" onClick={onClose} />
|
<div className="fixed inset-0 z-[35]" role="presentation" onClick={onClose} />
|
||||||
<div
|
<div
|
||||||
|
ref={panelRef}
|
||||||
className={`m-pop-in fixed z-[36] w-[228px] rounded-[20px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[6px] text-m-ink shadow-[0_24px_60px_-20px_rgba(0,0,0,.5)] backdrop-blur-[30px] backdrop-saturate-[1.8] ${className}`}
|
className={`m-pop-in fixed z-[36] w-[228px] rounded-[20px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[6px] text-m-ink shadow-[0_24px_60px_-20px_rgba(0,0,0,.5)] backdrop-blur-[30px] backdrop-saturate-[1.8] ${className}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode } from 'react'
|
import { type AriaAttributes, ReactNode } from 'react'
|
||||||
|
|
||||||
interface MIconBtnProps {
|
interface MIconBtnProps {
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
@@ -10,6 +10,10 @@ interface MIconBtnProps {
|
|||||||
className?: string
|
className?: string
|
||||||
/** Dimmed and inert. For a button whose position should stay put even when it cannot act. */
|
/** Dimmed and inert. For a button whose position should stay put even when it cannot act. */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
/** For a button that opens a panel: whether it is open now, said to a screen reader. */
|
||||||
|
ariaExpanded?: boolean
|
||||||
|
/** For a button that opens a panel: what kind it is (`menu` for a menu). */
|
||||||
|
ariaHaspopup?: AriaAttributes['aria-haspopup']
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +25,8 @@ export default function MIconBtn({
|
|||||||
size = 38,
|
size = 38,
|
||||||
className = '',
|
className = '',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
ariaExpanded,
|
||||||
|
ariaHaspopup,
|
||||||
children,
|
children,
|
||||||
}: MIconBtnProps) {
|
}: MIconBtnProps) {
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +34,8 @@ export default function MIconBtn({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
aria-expanded={ariaExpanded}
|
||||||
|
aria-haspopup={ariaHaspopup}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`relative flex flex-none items-center justify-center rounded-full text-m-ink disabled:opacity-35 ${
|
className={`relative flex flex-none items-center justify-center rounded-full text-m-ink disabled:opacity-35 ${
|
||||||
variant === 'glass'
|
variant === 'glass'
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ interface MListRowProps {
|
|||||||
danger?: boolean
|
danger?: boolean
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
|
/** The row is the one open now (a chat in a history list), said so to a screen reader too. */
|
||||||
|
current?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Menu/list row (user menu style): icon 16 in --m-muted, 13.5px semibold label. */
|
/** Menu/list row (user menu style): icon 16 in --m-muted, 13.5px semibold label. */
|
||||||
@@ -18,11 +20,13 @@ export default function MListRow({
|
|||||||
danger = false,
|
danger = false,
|
||||||
onClick,
|
onClick,
|
||||||
className = '',
|
className = '',
|
||||||
|
current = false,
|
||||||
}: MListRowProps) {
|
}: MListRowProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
aria-current={current || undefined}
|
||||||
className={`flex w-full items-center gap-[11px] rounded-xl px-[10px] py-[11px] text-start text-[0.84375rem] font-semibold active:bg-[color:var(--m-ic)] ${
|
className={`flex w-full items-center gap-[11px] rounded-xl px-[10px] py-[11px] text-start text-[0.84375rem] font-semibold active:bg-[color:var(--m-ic)] ${
|
||||||
danger ? 'text-[color:var(--m-st-danger)]' : 'text-m-ink'
|
danger ? 'text-[color:var(--m-st-danger)]' : 'text-m-ink'
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
|
|||||||
import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useDiscardGuard } from '../../components/shared/useDiscardGuard'
|
import { useDiscardGuard } from '../../components/shared/useDiscardGuard'
|
||||||
|
import { keepTabInside, useModalFocus } from './modalFocus'
|
||||||
|
|
||||||
export type MSheetVariant = 'card' | 'bottom' | 'drawer'
|
export type MSheetVariant = 'card' | 'bottom' | 'drawer'
|
||||||
export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque'
|
export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque'
|
||||||
@@ -34,9 +35,6 @@ const EXIT_MS = 280
|
|||||||
|
|
||||||
const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold text-white' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
|
const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold text-white' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
|
||||||
|
|
||||||
const FOCUSABLE =
|
|
||||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
||||||
|
|
||||||
const POSITION: Record<MSheetVariant, string> = {
|
const POSITION: Record<MSheetVariant, string> = {
|
||||||
card: 'absolute inset-x-[14px] top-1/2 -translate-y-1/2',
|
card: 'absolute inset-x-[14px] top-1/2 -translate-y-1/2',
|
||||||
bottom: 'absolute inset-x-4 bottom-[calc(var(--bottom-nav-h,84px)+16px)]',
|
bottom: 'absolute inset-x-4 bottom-[calc(var(--bottom-nav-h,84px)+16px)]',
|
||||||
@@ -128,12 +126,7 @@ export default function MSheet({
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Move focus into the dialog on open, hand it back on close.
|
// Move focus into the dialog on open, hand it back on close.
|
||||||
useEffect(() => {
|
useModalFocus(panelRef, open && rendered)
|
||||||
if (!open || !rendered) return
|
|
||||||
const previous = document.activeElement as HTMLElement | null
|
|
||||||
panelRef.current?.focus()
|
|
||||||
return () => previous?.focus()
|
|
||||||
}, [open, rendered])
|
|
||||||
|
|
||||||
if (!rendered) return null
|
if (!rendered) return null
|
||||||
|
|
||||||
@@ -164,25 +157,7 @@ export default function MSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Keep Tab inside the dialog while it is open.
|
// Keep Tab inside the dialog while it is open.
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => keepTabInside(e, panelRef.current)
|
||||||
if (e.key !== 'Tab') return
|
|
||||||
const panel = panelRef.current
|
|
||||||
if (!panel) return
|
|
||||||
const focusable = panel.querySelectorAll<HTMLElement>(FOCUSABLE)
|
|
||||||
if (focusable.length === 0) {
|
|
||||||
e.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const first = focusable[0]
|
|
||||||
const last = focusable[focusable.length - 1]
|
|
||||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
|
|
||||||
e.preventDefault()
|
|
||||||
last.focus()
|
|
||||||
} else if (!e.shiftKey && document.activeElement === last) {
|
|
||||||
e.preventDefault()
|
|
||||||
first.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const enterClass = variant === 'drawer' ? 'm-drawer-in' : 'm-sheet-in'
|
const enterClass = variant === 'drawer' ? 'm-drawer-in' : 'm-sheet-in'
|
||||||
const exitClass = variant === 'drawer' ? 'm-drawer-out' : 'm-sheet-out'
|
const exitClass = variant === 'drawer' ? 'm-drawer-out' : 'm-sheet-out'
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { type KeyboardEvent, type RefObject, useEffect } from 'react';
|
||||||
|
|
||||||
|
const FOCUSABLE =
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps Tab inside a modal panel: past its last control it goes round to the
|
||||||
|
* first, and Shift+Tab from the first one (or from the panel itself) to the
|
||||||
|
* last. A panel with nothing to focus keeps the focus where it is.
|
||||||
|
*/
|
||||||
|
export function keepTabInside(e: KeyboardEvent, panel: HTMLElement | null): void {
|
||||||
|
if (e.key !== 'Tab' || !panel) return;
|
||||||
|
const focusable = panel.querySelectorAll<HTMLElement>(FOCUSABLE);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (e.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While `shown`, the focus sits on the modal panel (which takes it with
|
||||||
|
* `tabIndex={-1}`); once it is no longer shown, the focus goes back to where
|
||||||
|
* it was before.
|
||||||
|
*/
|
||||||
|
export function useModalFocus(panel: RefObject<HTMLElement | null>, shown: boolean): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shown) return;
|
||||||
|
const previous = document.activeElement as HTMLElement | null;
|
||||||
|
panel.current?.focus();
|
||||||
|
return () => previous?.focus();
|
||||||
|
}, [panel, shown]);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user