mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(studio): rebuild both side panels around one head, one row and one order
The rail lists its sections in the order a book is made: Pages, Content, Layouts, Elements and Travel, then Theme and Layers set apart below a rule. Stickers are a tab of Elements beside the icons instead of a rail section of their own, and Elements has six tabs (text, shapes, lines, frames with the grids, icons, stickers). The quotation mark moved to the text styles, the outline rectangle to the basic shapes and the plain rule to the lines. The rail and every tab row are tab lists the arrow keys walk through, and a long name wraps instead of being cut. The icon and sticker searches say in the field how many match, which the sticker library's own head used to. Every panel on both sides opens on the same head, and every labelled control uses one row that moves the control under its label when the two do not fit, so no label sits in a fixed column any more. Number fields, chips, sliders, tab labels, theme names and travel cards wrap the same way, a country beside a stop wraps under itself, and an upload cell holds its words to two lines with the whole label in its tooltip. The heads, shelves and groups are real headings, so a screen reader can walk the panels by them. The inspector shows every kind in one order: what the element is and says, its position and size, its look, its effects, then Arrange. Align and the stacking buttons are one Arrange group with a button that opens the page in Layers; its rows sit under their labels, each in two sets that move to a second line whole. Rarely used groups start folded and open on an element that already uses them (a turned element, a map held at a zoom, a summary set in another face), and every group mounts afresh for the next selection. A sticker's duplicate angle and opacity are gone in favour of the shared position group, which now has the turn buttons for every kind. With several elements selected it keeps their angle, opacity and turn, and the mirror when all of them are stickers, and leaves the frame's numbers to one element. A photo's fill page and fill spread sit under its crop. With nothing selected the inspector shows the page background, the book's theme with the way to the Theme panel, and the page numbers. Seventeen translation keys nothing has used since before the rebuild (the old inspector title, the empty spread note, the summary and country list names, the roads buttons and others) are gone from every language. The broad key template the old panels used had counted them as reached; the format names, the empty photo grid messages and the map source hints are now named where the key check can read them. Also fixed on the way: the travel stops list shared its class with the gradient stops and was laid out as sixteen pixel handles; the alignment buttons were named in raw English; the map's print resolution note joined its two halves with a dash.
This commit is contained in:
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
|
||||
// ── Studio ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const rail = (page: Page) => page.locator('.st-rail')
|
||||
const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true })
|
||||
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true })
|
||||
const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
|
||||
const inspector = (page: Page) => page.locator('.st-inspector')
|
||||
const studioMenu = (page: Page) => page.locator('.st-menu').last()
|
||||
@@ -1431,7 +1431,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
|
||||
await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
|
||||
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 401,
|
||||
"@typescript-eslint/no-misused-promises": 850,
|
||||
"@typescript-eslint/no-non-null-assertion": 385,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 226,
|
||||
"complexity": 222,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 33,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 556,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 125,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 132,
|
||||
"max-lines-per-function": 130,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1932,
|
||||
"@typescript-eslint/no-non-null-assertion": 1931,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -117,11 +117,6 @@ export const DYNAMIC_ALLOWED = [
|
||||
template: 'settings.${mode}',
|
||||
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
|
||||
},
|
||||
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
|
||||
file,
|
||||
template: 'journey.studio.${key}',
|
||||
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
|
||||
})),
|
||||
{
|
||||
file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
|
||||
template: 'reservations.${s}',
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
||||
"src/components/Studio/StudioShell.tsx": 1,
|
||||
"src/components/Studio/StudioSidebar.tsx": 1,
|
||||
"src/components/Studio/TravelElements.tsx": 1,
|
||||
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
|
||||
"src/components/Todo/useTodoList.ts": 1,
|
||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||
"src/components/shared/CountryFlag.tsx": 1,
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"components/Studio/StudioCanvas.tsx": 12,
|
||||
"components/Studio/StudioElementsPanel.tsx": 1,
|
||||
"components/Studio/StudioExport.tsx": 1,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
"components/Studio/StudioSavedLayouts.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 4,
|
||||
"components/Studio/TravelPreview.tsx": 1,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"components/Studio/LineHandles.tsx": 1,
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1,
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4
|
||||
}
|
||||
|
||||
@@ -26,5 +26,5 @@
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 2057
|
||||
"src/styles/studio.css": 2009
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
"src/components/Settings/NotificationsTab.test.tsx": 1,
|
||||
"src/components/Settings/PasskeysSection.tsx": 1,
|
||||
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
|
||||
"src/components/Studio/StudioInspector.tsx": 1,
|
||||
"src/components/Todo/todoListModel.ts": 1,
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||
"src/components/Tours/planner/useTourPlanner.ts": 2,
|
||||
|
||||
@@ -103,11 +103,10 @@
|
||||
"components/Studio/PhotoView.tsx": 2,
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/StudioElementsPanel.tsx": 8,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
"components/Studio/StudioShell.tsx": 2,
|
||||
"components/Studio/StudioSidebar.tsx": 2,
|
||||
"components/Studio/TravelElements.tsx": 5,
|
||||
"components/Studio/printSheets.ts": 5,
|
||||
"components/Studio/sidebar/contentDrops.ts": 2,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
|
||||
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
|
||||
"components/SystemNotices/SystemNoticeModal.tsx": 107,
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
AlignStartHorizontal,
|
||||
AlignStartVertical,
|
||||
AlignVerticalDistributeCenter,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronsDown,
|
||||
ChevronsUp,
|
||||
Group,
|
||||
Layers,
|
||||
Paintbrush,
|
||||
Pipette,
|
||||
Ungroup,
|
||||
@@ -18,17 +23,32 @@ import {
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-panels.css';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { Choice, Section } from './StudioControls';
|
||||
import { Choice, Line, Section } from './StudioControls';
|
||||
import { useStudioShortcut } from './studioShortcut';
|
||||
|
||||
/**
|
||||
* Align, distribute, group and the style clipboard, for whatever is selected.
|
||||
* Arrange: where the selection sits among the other things on the page.
|
||||
*
|
||||
* The align bar lines a multi-selection up within its own box by default, and
|
||||
* Lining it up and spacing it out, what it lines up against, what lies in
|
||||
* front of what, grouping, and the style clipboard, in one group at the foot
|
||||
* of the inspector. These were two groups, Align and Arrange, one under the
|
||||
* other and both about the selection's place among its neighbours, so the
|
||||
* stacking buttons were a fold away from the alignment they are used with.
|
||||
*
|
||||
* Every line puts its row under its label, across the whole width of the
|
||||
* panel: the six edges and the two spacings are eight buttons, more than fit
|
||||
* beside a label. Each row is two sets, the edges and the spacings, the stack
|
||||
* and the grouping, and a set that no longer fits moves to the next line
|
||||
* whole, so a narrow panel shows two even rows rather than a ragged one.
|
||||
*
|
||||
* The align row lines a multi-selection up within its own box by default, and
|
||||
* a single element (or one whole group) against its page, since there is
|
||||
* nothing else for it to line up with. Groups move as one box throughout
|
||||
* (shared/src/book/book-arrange.ts). Every press is one undo step.
|
||||
* (shared/src/book/book-arrange.ts). The Layers button opens the rail's
|
||||
* Layers panel, the whole stack of the page beside the selection. Every press
|
||||
* is one undo step.
|
||||
*/
|
||||
|
||||
const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
|
||||
@@ -40,6 +60,13 @@ const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
|
||||
{ edge: 'bottom', icon: AlignEndHorizontal, labelKey: 'journey.studio.layers.alignBottom' },
|
||||
];
|
||||
|
||||
const STACK: { to: 'front' | 'up' | 'down' | 'back'; icon: LucideIcon; labelKey: string }[] = [
|
||||
{ to: 'front', icon: ChevronsUp, labelKey: 'journey.studio.toFront' },
|
||||
{ to: 'up', icon: ArrowUp, labelKey: 'journey.studio.forward' },
|
||||
{ to: 'down', icon: ArrowDown, labelKey: 'journey.studio.backward' },
|
||||
{ to: 'back', icon: ChevronsDown, labelKey: 'journey.studio.toBack' },
|
||||
];
|
||||
|
||||
/** How many things move as one in a selection: a group counts once. */
|
||||
function unitCount(spread: BookSpread, ids: string[]): number {
|
||||
return distributableCount(
|
||||
@@ -70,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 selection = useStudioStore((s) => s.selection);
|
||||
const hasStyle = useStudioStore((s) => s.styleClip !== null);
|
||||
const align = useStudioStore((s) => s.align);
|
||||
const distribute = useStudioStore((s) => s.distribute);
|
||||
const raise = useStudioStore((s) => s.raise);
|
||||
const group = useStudioStore((s) => s.group);
|
||||
const ungroup = useStudioStore((s) => s.ungroup);
|
||||
const copyStyle = useStudioStore((s) => s.copyStyle);
|
||||
@@ -95,76 +132,102 @@ export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number;
|
||||
|
||||
return (
|
||||
// Open for a multi-selection, where lining up is what comes next; folded for one element.
|
||||
<Section key={units > 1 ? 'many' : 'one'} label={t('journey.studio.layers.align')} defaultOpen={units > 1}>
|
||||
<div className="st-row">
|
||||
{ALIGN.map((a) => (
|
||||
<IconButton
|
||||
key={a.edge}
|
||||
label={t(a.labelKey)}
|
||||
icon={a.icon}
|
||||
disabled={!movable.length}
|
||||
onClick={() => align(spreadIndex, a.edge, to)}
|
||||
<Section key={units > 1 ? 'many' : 'one'} label={t('journey.studio.arrange')} defaultOpen={units > 1}>
|
||||
<div className="st-arrange">
|
||||
<Line label={t('journey.studio.layers.align')} wide>
|
||||
<div className="st-row st-arrange-row">
|
||||
<span className="st-arrange-set">
|
||||
{ALIGN.map((a) => (
|
||||
<IconButton
|
||||
key={a.edge}
|
||||
label={t(a.labelKey)}
|
||||
icon={a.icon}
|
||||
disabled={!movable.length}
|
||||
onClick={() => align(spreadIndex, a.edge, to)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="st-arrange-set">
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.distributeH')}
|
||||
icon={AlignHorizontalDistributeCenter}
|
||||
disabled={units < 3}
|
||||
onClick={() => distribute(spreadIndex, 'horizontal')}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.distributeV')}
|
||||
icon={AlignVerticalDistributeCenter}
|
||||
disabled={units < 3}
|
||||
onClick={() => distribute(spreadIndex, 'vertical')}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.layers.alignTo')} wide>
|
||||
<Choice<BookAlignTo>
|
||||
value={to}
|
||||
ariaLabel={t('journey.studio.layers.alignTo')}
|
||||
options={[
|
||||
{ value: 'selection' as const, label: t('journey.studio.layers.toSelection'), disabled: units < 2 },
|
||||
{ value: 'page' as const, label: t('journey.studio.layers.toPage') },
|
||||
{ value: 'safe' as const, label: t('journey.studio.layers.toSafe') },
|
||||
]}
|
||||
onPick={setChosenTo}
|
||||
/>
|
||||
))}
|
||||
<span className="st-row-sep" aria-hidden />
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.distributeH')}
|
||||
icon={AlignHorizontalDistributeCenter}
|
||||
disabled={units < 3}
|
||||
onClick={() => distribute(spreadIndex, 'horizontal')}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.distributeV')}
|
||||
icon={AlignVerticalDistributeCenter}
|
||||
disabled={units < 3}
|
||||
onClick={() => distribute(spreadIndex, 'vertical')}
|
||||
/>
|
||||
</div>
|
||||
<div className="st-arrange-to">
|
||||
<span className="st-line-label">{t('journey.studio.layers.alignTo')}</span>
|
||||
<Choice<BookAlignTo>
|
||||
value={to}
|
||||
ariaLabel={t('journey.studio.layers.alignTo')}
|
||||
options={[
|
||||
{ value: 'selection' as const, label: t('journey.studio.layers.toSelection'), disabled: units < 2 },
|
||||
{ value: 'page' as const, label: t('journey.studio.layers.toPage') },
|
||||
{ value: 'safe' as const, label: t('journey.studio.layers.toSafe') },
|
||||
]}
|
||||
onPick={setChosenTo}
|
||||
/>
|
||||
</div>
|
||||
<div className="st-row st-arrange-acts">
|
||||
{(canGroup || !grouped) && (
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.groupAction')}
|
||||
icon={Group}
|
||||
disabled={!canGroup}
|
||||
shortcut={keys('G')}
|
||||
onClick={() => group(spreadIndex)}
|
||||
/>
|
||||
)}
|
||||
{grouped && (
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.ungroup')}
|
||||
icon={Ungroup}
|
||||
shortcut={keys('⇧', 'G')}
|
||||
onClick={() => ungroup(spreadIndex)}
|
||||
/>
|
||||
)}
|
||||
<span className="st-row-sep" aria-hidden />
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.copyStyle')}
|
||||
icon={Pipette}
|
||||
shortcut={keys('alt', 'C')}
|
||||
onClick={() => copyStyle(spreadIndex)}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.pasteStyle')}
|
||||
icon={Paintbrush}
|
||||
disabled={!hasStyle || !movable.length}
|
||||
shortcut={keys('alt', 'V')}
|
||||
onClick={() => pasteStyle(spreadIndex)}
|
||||
/>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.order')} wide>
|
||||
<div className="st-row st-arrange-row">
|
||||
<span className="st-arrange-set">
|
||||
{STACK.map((s) => (
|
||||
<IconButton
|
||||
key={s.to}
|
||||
label={t(s.labelKey)}
|
||||
icon={s.icon}
|
||||
onClick={() => raise(spreadIndex, selection, s.to)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="st-arrange-set">
|
||||
{(canGroup || !grouped) && (
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.groupAction')}
|
||||
icon={Group}
|
||||
disabled={!canGroup}
|
||||
shortcut={keys('G')}
|
||||
onClick={() => group(spreadIndex)}
|
||||
/>
|
||||
)}
|
||||
{grouped && (
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.ungroup')}
|
||||
icon={Ungroup}
|
||||
shortcut={keys('⇧', 'G')}
|
||||
onClick={() => ungroup(spreadIndex)}
|
||||
/>
|
||||
)}
|
||||
{onShowLayers && (
|
||||
<IconButton label={t('journey.studio.showLayers')} icon={Layers} onClick={onShowLayers} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.style')} wide>
|
||||
<div className="st-row">
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.copyStyle')}
|
||||
icon={Pipette}
|
||||
shortcut={keys('alt', 'C')}
|
||||
onClick={() => copyStyle(spreadIndex)}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.pasteStyle')}
|
||||
icon={Paintbrush}
|
||||
disabled={!hasStyle || !movable.length}
|
||||
shortcut={keys('alt', 'V')}
|
||||
onClick={() => pasteStyle(spreadIndex)}
|
||||
/>
|
||||
</div>
|
||||
</Line>
|
||||
</div>
|
||||
{asGroup && <p className="st-hint st-arrange-hint">{t('journey.studio.layers.groupHint')}</p>}
|
||||
</Section>
|
||||
|
||||
@@ -13,9 +13,10 @@ import type { PhotoFilter } from './photoFilter'
|
||||
* heading over the grid rather than as one control among several, and a long
|
||||
* entry name has somewhere to go.
|
||||
*
|
||||
* The popover is the top bar's format menu, hung from the left edge instead of
|
||||
* the right: the side panel is 236px wide and a menu anchored right would open
|
||||
* into the rail.
|
||||
* The popover is the top bar's format menu, hung from the start edge instead
|
||||
* of the end: the side panel is 236px wide and a menu anchored at the end would
|
||||
* open into the rail. A long entry name takes two lines in the chip, and the
|
||||
* menu shows every name whole.
|
||||
*/
|
||||
export function StudioContentFilter({
|
||||
source, filter, setFilter, admitted, pageEntryId, t, locale,
|
||||
@@ -79,7 +80,7 @@ export function StudioContentFilter({
|
||||
aria-expanded={open}
|
||||
title={t('journey.studio.filterPhotos')}
|
||||
>
|
||||
<span className="st-filter-name">{label}</span>
|
||||
<span className="st-filter-name" title={label}>{label}</span>
|
||||
<em>{admitted}</em>
|
||||
<ChevronDown size={12} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
@@ -31,10 +31,15 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
/**
|
||||
* One group of properties.
|
||||
*
|
||||
* `defaultOpen={false}` is for the groups you set once and rarely return to —
|
||||
* the frame's numbers, mostly. The state is per mounted section rather than
|
||||
* remembered: coming back to an element and finding its panel folded up
|
||||
* differently from last time is worse than opening one group again.
|
||||
* `defaultOpen={false}` is for the groups you set once and rarely return to:
|
||||
* the frame's numbers, the effects, the advanced settings of a kind. The
|
||||
* inspector opens such a group anyway when the element already uses it, so a
|
||||
* photo with a duotone shows its duotone and a plain one keeps it out of the
|
||||
* way (inspector/inspectorModel.ts says which groups are in use). The state
|
||||
* is per mounted section rather than remembered: the inspector mounts its
|
||||
* groups afresh for every selection, and coming back to an element and finding
|
||||
* its panel folded up differently from last time is worse than opening one
|
||||
* group again.
|
||||
*/
|
||||
export function Section({ label, children, defaultOpen = true, hint, badge }: {
|
||||
label: string
|
||||
@@ -51,15 +56,19 @@ export function Section({ label, children, defaultOpen = true, hint, badge }: {
|
||||
badge?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const body = useId()
|
||||
return (
|
||||
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
|
||||
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}>
|
||||
<span className="st-section-label">{label}</span>
|
||||
{badge && <span className="st-section-badge">{badge}</span>}
|
||||
<ChevronDown size={13} className="st-section-caret" />
|
||||
</button>
|
||||
<h3 className="st-section-title">
|
||||
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}
|
||||
aria-controls={open ? body : undefined}>
|
||||
<span className="st-section-label">{label}</span>
|
||||
{badge && <span className="st-section-badge">{badge}</span>}
|
||||
<ChevronDown size={13} className="st-section-caret" aria-hidden />
|
||||
</button>
|
||||
</h3>
|
||||
{open && (
|
||||
<div className="st-section-body">
|
||||
<div className="st-section-body" id={body}>
|
||||
{hint && <p className="st-hint st-section-hint">{hint}</p>}
|
||||
{children}
|
||||
</div>
|
||||
@@ -68,7 +77,17 @@ export function Section({ label, children, defaultOpen = true, hint, badge }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** A labelled line. The label sits left, the control fills what is left. */
|
||||
/**
|
||||
* A labelled line: the label first, the control after it.
|
||||
*
|
||||
* The two share the line while they fit and the control moves under the label
|
||||
* when they do not, so a long label ("Πεζά και κεφαλαία", "Átlátszatlanság")
|
||||
* pushes its control down a line instead of being cut or squeezing the control
|
||||
* to nothing. There is no column of a fixed width for the label to fit into;
|
||||
* short labels sit at a common minimum, so the controls of a group still start
|
||||
* at one edge. `wide` puts the control under the label always, for a control
|
||||
* that needs the whole width, like a row of swatches.
|
||||
*/
|
||||
export function Line({ label, children, wide = false }: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
bookShapeTakesEdge,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { NumField, Section } from './StudioControls';
|
||||
import { edgeInUse } from './inspector/inspectorModel';
|
||||
import { DEFAULT_EDGE_SIZE } from './shapeOutline';
|
||||
import { edgedPath } from './shapes-edges';
|
||||
import { NumField, Section } from './StudioControls';
|
||||
|
||||
/**
|
||||
* The edge of a shape or of a photo's cut: a row of tiles, each the edge drawn
|
||||
@@ -50,12 +51,8 @@ export function EdgeSection({
|
||||
// A heart or a star keeps its drawn line, so there is nothing to offer unless an edge is already set on one.
|
||||
if (!takes && current === 'straight') return null;
|
||||
return (
|
||||
<Section label={t('journey.studio.edgeStyle')} defaultOpen={current !== 'straight'}>
|
||||
{!takes && (
|
||||
<p className="st-hint" style={{ paddingBottom: 8 }}>
|
||||
{t('journey.studio.edgeUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
<Section label={t('journey.studio.edgeStyle')} defaultOpen={edgeInUse(edge)}>
|
||||
{!takes && <p className="st-hint st-hint-before">{t('journey.studio.edgeUnavailable')}</p>}
|
||||
<div className="st-mini-shapes">
|
||||
{BOOK_EDGES.map((e) => (
|
||||
<button
|
||||
@@ -72,7 +69,7 @@ export function EdgeSection({
|
||||
))}
|
||||
</div>
|
||||
{takes && current !== 'straight' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.edgeSize')}
|
||||
value={edgeSize ?? DEFAULT_EDGE_SIZE}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { BookDocument } from '@trek/shared';
|
||||
import { BOOK_GRIDS, 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-006: the Elements panel's six
|
||||
* tabs, each opening its own shelves, and each placing what it shows on the
|
||||
* open page.
|
||||
*/
|
||||
|
||||
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-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,44 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Minus, Quote, Search, Square, X } from 'lucide-react'
|
||||
import { useId, useState } from 'react'
|
||||
import { Frame, Minus, Quote, Shapes, Spline, Sparkles, Square, Sticker, Type, type LucideIcon } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS } from './shapes'
|
||||
import { BOOK_GRIDS as GRIDS, bookElementId, bookShapeSize, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared'
|
||||
import { shapeTile } from './shapeOutline'
|
||||
import { StudioLineShelf } from './StudioLineShelf'
|
||||
import { PanelHead } from './StudioPanelHead'
|
||||
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
|
||||
import '../../styles/studio-elements.css'
|
||||
import { PanelHead, Shelf } from './StudioPanelHead'
|
||||
import { StudioTabPanel, StudioTabs } from './StudioTabs'
|
||||
import { StudioStickersPanel } from './StudioStickersPanel'
|
||||
import { StudioIconShelf } from './sidebar/StudioIconShelf'
|
||||
import { FRAME_STYLE_OPTIONS, type FrameStyle } from './studioOptions'
|
||||
import { StudioTextStyles } from './StudioTextStyles'
|
||||
import type { ShelfSource } from './textStylesModel'
|
||||
|
||||
/**
|
||||
* Everything you can add that does not come from the journey: type, shapes,
|
||||
* picture frames, photo grids and the icon set.
|
||||
* lines, picture frames and grids, the icon set and the stickers.
|
||||
*
|
||||
* Four tabs rather than four more entries on the rail. They are all the same
|
||||
* kind of answer to "what do I put on this page", and a rail long enough to
|
||||
* hold them separately stops being faster to hit than a word would be.
|
||||
* One panel with six tabs rather than six entries on the rail. They are all
|
||||
* the same kind of answer to "what do I put on this page", and a rail long
|
||||
* enough to hold them separately stops being faster to hit than a word would
|
||||
* be. The stickers were a rail section of their own until the rebuild; they
|
||||
* are one more kind of thing to add, so they sit here beside the icons.
|
||||
*/
|
||||
|
||||
// The book's own ids, the ones the layouts and the assistant mint (shared/src/book/book-ids.ts).
|
||||
const uid = bookElementId
|
||||
|
||||
/** How many icons the grid draws at a time. Two screens' worth at four across. */
|
||||
const ICON_PAGE = 120
|
||||
type Tab = 'text' | 'shapes' | 'lines' | 'frames' | 'icons' | 'stickers'
|
||||
|
||||
type FrameStyle = 'none' | 'polaroid' | 'white' | 'shadow' | 'film' | 'tape'
|
||||
/** The tabs, three to a row: a word under each icon, so the longest of them keeps its cell. */
|
||||
const TABS: readonly { id: Tab; icon: LucideIcon; labelKey: string }[] = [
|
||||
{ id: 'text', icon: Type, labelKey: 'journey.studio.text' },
|
||||
{ id: 'shapes', icon: Shapes, labelKey: 'journey.studio.shapes' },
|
||||
{ id: 'lines', icon: Spline, labelKey: 'journey.studio.lines' },
|
||||
{ id: 'frames', icon: Frame, labelKey: 'journey.studio.frames' },
|
||||
{ id: 'icons', icon: Sparkles, labelKey: 'journey.studio.icons' },
|
||||
{ id: 'stickers', icon: Sticker, labelKey: 'journey.studio.stickers' },
|
||||
]
|
||||
|
||||
/**
|
||||
* A shape as a tile.
|
||||
@@ -78,103 +89,34 @@ function FrameStyleGlyph({ style }: { style: FrameStyle }) {
|
||||
}}
|
||||
>
|
||||
<span className="st-frame-glyph-photo" style={{ inset: `${pad}px ${pad}px ${bottom}px ${pad}px` }} />
|
||||
{/* Both strips, because both is what gets placed — a preview showing one
|
||||
{/* Both strips, because both is what gets placed: a preview showing one
|
||||
made the second one look like a bug the first time it appeared. */}
|
||||
{style === 'tape' && <><em className="st-frame-glyph-tape" /><em className="st-frame-glyph-tape is-right" /></>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function IconCell({ name, onPick }: { name: string; onPick: (name: string) => void }) {
|
||||
const Icon = iconComponent(name)
|
||||
return (
|
||||
<button type="button"
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => onPick(name)}
|
||||
aria-label={name}
|
||||
title={iconLabel(name)}
|
||||
>
|
||||
<Icon strokeWidth={1.7} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** A plain text style: size, weight, its sample words and anything else it sets. */
|
||||
type AddText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra?: Partial<BookElement>) => void
|
||||
|
||||
/** The type tab: the four plain styles, then the lockups of the Text styles shelf. */
|
||||
function TextTab({ page, source, t, addText }: {
|
||||
page: BookPageSetup
|
||||
source: ShelfSource
|
||||
t: (k: string) => string
|
||||
addText: AddText
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.text')}</div>
|
||||
<div className="st-stack">
|
||||
<button type="button" className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
|
||||
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
|
||||
{t('journey.studio.styleTitle')}
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
|
||||
</button>
|
||||
<button type="button" className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
|
||||
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-tile"
|
||||
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
|
||||
>
|
||||
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
|
||||
{t('journey.studio.styleCaption')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StudioTextStyles page={page} source={source} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudioElementsPanel({ page, t, source }: {
|
||||
page: BookPageSetup
|
||||
t: (k: string) => string
|
||||
/** The journal, for the year and the point a lockup is filled with. */
|
||||
source: ShelfSource
|
||||
}) {
|
||||
const [tab, setTab] = useState<'text' | 'shapes' | 'frames' | 'grids' | 'icons'>('text')
|
||||
const [iconQuery, setIconQuery] = useState('')
|
||||
/*
|
||||
* How much of the library is on screen.
|
||||
*
|
||||
* Fourteen hundred icons is fourteen hundred React components, each a
|
||||
* forwardRef around several SVG nodes, and rendering them all costs about a
|
||||
* second of blocked main thread on the first click of the tab — for a grid
|
||||
* whose first two rows are all anybody sees. So it grows as it is scrolled,
|
||||
* and a search resets it: the results of a search are short, and starting
|
||||
* them part-drawn would be wrong.
|
||||
*/
|
||||
const [iconLimit, setIconLimit] = useState(ICON_PAGE)
|
||||
const iconTail = useRef<HTMLDivElement>(null)
|
||||
/**
|
||||
* What every tab places, and where: centred on the page that is open, across
|
||||
* both pages of a spread or on the one page of a cover.
|
||||
*/
|
||||
function useElementAdders(page: BookPageSetup) {
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const select = useStudioStore(s => s.select)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
const single = !!spread && spread.role !== 'inner'
|
||||
const role = useStudioStore(s => s.doc?.spreads[s.activeSpread]?.role)
|
||||
const single = role !== undefined && role !== 'inner'
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
}
|
||||
|
||||
const addText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra: Partial<BookElement> = {}) =>
|
||||
const addText: AddText = (size, weight, sample, extra = {}) =>
|
||||
addElement(active, {
|
||||
id: uid('t'), kind: 'text', frame: centre(page.pageWidth * 0.7, size * 0.5 + 8),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
@@ -205,7 +147,7 @@ export function StudioElementsPanel({ page, t, source }: {
|
||||
: own ? centre(own.w, own.h)
|
||||
: centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
// A rule is a shape too — a hairline box rather than its own element type,
|
||||
// A rule is a shape too: a hairline box rather than its own element type,
|
||||
// so it moves, colours and snaps like everything else on the page.
|
||||
shape: isRule ? 'rect' : shape,
|
||||
fill: opts.outline ? null : '#141414',
|
||||
@@ -218,11 +160,9 @@ export function StudioElementsPanel({ page, t, source }: {
|
||||
}
|
||||
|
||||
/**
|
||||
* An icon, placed as a square.
|
||||
*
|
||||
* Square because that is what it is: unlike a shape, an icon keeps its
|
||||
* proportions inside whatever frame it is given, so a rectangle would only
|
||||
* pad it with air on two sides.
|
||||
* An icon, placed as a square. Unlike a shape, an icon keeps its proportions
|
||||
* inside whatever frame it is given, so a rectangle would only pad it with
|
||||
* air on two sides.
|
||||
*/
|
||||
const addIcon = (name: string) => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.18
|
||||
@@ -247,240 +187,229 @@ export function StudioElementsPanel({ page, t, source }: {
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
const matchedIcons = useMemo(() => searchIcons(iconQuery), [iconQuery])
|
||||
const shownIcons = useMemo(() => matchedIcons.slice(0, iconLimit), [matchedIcons, iconLimit])
|
||||
/** A block of empty frames, selected as a group so it moves and resizes as one thing straight away. */
|
||||
const addGrid = (grid: (typeof GRIDS)[number]) => {
|
||||
const els = gridElements(grid, defaultGridBox(page, single))
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
|
||||
}))
|
||||
select(els.map(e => e.id))
|
||||
}
|
||||
|
||||
/*
|
||||
* Grow the grid when its foot comes into view.
|
||||
*
|
||||
* An observer rather than a scroll handler: the panel is the scroller, the
|
||||
* foot is the thing that matters, and asking the browser to tell us when they
|
||||
* meet costs nothing per frame.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const tail = iconTail.current
|
||||
if (!tail) return
|
||||
const io = new IntersectionObserver(entries => {
|
||||
if (entries.some(e => e.isIntersecting)) setIconLimit(n => n + ICON_PAGE)
|
||||
})
|
||||
io.observe(tail)
|
||||
return () => io.disconnect()
|
||||
}, [shownIcons.length, matchedIcons.length])
|
||||
return { active, single, addElement, addText, addShape, addIcon, addFrame, addGrid }
|
||||
}
|
||||
|
||||
type Adders = ReturnType<typeof useElementAdders>
|
||||
|
||||
/** The type tab: the plain styles and the big quotation mark, then the lockups of the Text styles shelf. */
|
||||
function TextTab({ page, source, t, addText }: {
|
||||
page: BookPageSetup
|
||||
source: ShelfSource
|
||||
t: (k: string) => string
|
||||
addText: AddText
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Shelf label={t('journey.studio.text')}>
|
||||
<div className="st-stack">
|
||||
<button type="button" className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
|
||||
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
|
||||
{t('journey.studio.styleTitle')}
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
|
||||
</button>
|
||||
<button type="button" className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
|
||||
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-tile"
|
||||
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
|
||||
>
|
||||
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
|
||||
{t('journey.studio.styleCaption')}
|
||||
</span>
|
||||
</button>
|
||||
{/* A mark of type rather than a shape: it sets in the face the rest of the page is set in. */}
|
||||
<button type="button"
|
||||
className="st-tile"
|
||||
onClick={() => addText(46, 700, '“', { color: '#c9c2b4', leading: 0.9 })}
|
||||
>
|
||||
<Quote size={16} strokeWidth={1.6} aria-hidden />
|
||||
<span>{t('journey.studio.quoteMark')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
<StudioTextStyles page={page} source={source} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Every shape by its group, the outline-only rectangle first among the basic ones. */
|
||||
function ShapesTab({ addShape, t }: { addShape: Adders['addShape']; t: (k: string) => string }) {
|
||||
return (
|
||||
<>
|
||||
{SHAPE_GROUPS.map(group => (
|
||||
<Shelf key={group.id} label={t(`journey.studio.shapeGroup.${group.id}`)}>
|
||||
<div className="st-shape-grid">
|
||||
{group.id === 'basic' && (
|
||||
<button type="button"
|
||||
className="st-shape-btn"
|
||||
onClick={() => addShape('rect', { outline: true })}
|
||||
title={t('journey.studio.shapeKind.outline')}
|
||||
aria-label={t('journey.studio.shapeKind.outline')}
|
||||
>
|
||||
<Square size={20} strokeWidth={1.1} aria-hidden style={{ opacity: .5 }} />
|
||||
</button>
|
||||
)}
|
||||
{group.shapes.map(shape => (
|
||||
<button type="button"
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => addShape(shape)}
|
||||
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 (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.elements')} />
|
||||
|
||||
<div className="st-tabs is-fluid">
|
||||
<button type="button" className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
|
||||
{t('journey.studio.text')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}>
|
||||
{t('journey.studio.shapes')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'frames' ? 'is-on' : ''} onClick={() => setTab('frames')}>
|
||||
{t('journey.studio.frames')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'grids' ? 'is-on' : ''} onClick={() => setTab('grids')}>
|
||||
{t('journey.studio.grids')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'icons' ? 'is-on' : ''} onClick={() => setTab('icons')}>
|
||||
{t('journey.studio.icons')}
|
||||
</button>
|
||||
</div>
|
||||
<StudioTabs<Tab>
|
||||
idBase={tabsId}
|
||||
label={t('journey.studio.elements')}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
columns={3}
|
||||
tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))}
|
||||
/>
|
||||
|
||||
<div className="st-panel-scroll">
|
||||
<StudioTabPanel idBase={tabsId} value={tab} className="st-tab-body">
|
||||
{/*
|
||||
Type first: the plain styles, then the lockups, set titles and marks
|
||||
drawn with the book's own words (StudioTextStyles.tsx).
|
||||
The icons and the stickers carry a search of their own, which sits
|
||||
above their scrolling grid like the Content panel's above its own;
|
||||
the other tabs are short shelves and scroll as they are.
|
||||
*/}
|
||||
{tab === 'text' && <TextTab page={page} source={source} t={t} addText={addText} />}
|
||||
|
||||
{tab === 'shapes' && (
|
||||
<>
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.lines')}</div>
|
||||
<div className="st-shape-grid">
|
||||
<button type="button" className="st-shape-btn" onClick={() => addShape('line')} title={t('journey.studio.shapeKind.line')}>
|
||||
<Minus size={20} strokeWidth={2} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-shape-btn"
|
||||
onClick={() => addShape('rect', { outline: true })}
|
||||
title={t('journey.studio.shapeKind.outline')}
|
||||
>
|
||||
<Square size={20} strokeWidth={1.1} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-shape-btn"
|
||||
onClick={() => addText(46, 700, '“', { color: '#c9c2b4', leading: 0.9 })}
|
||||
title={t('journey.studio.quoteMark')}
|
||||
>
|
||||
<Quote size={20} strokeWidth={1.6} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StudioLineShelf page={page} single={single} onAdd={el => addElement(active, el)} t={t} />
|
||||
|
||||
{SHAPE_GROUPS.map(group => (
|
||||
<div className="st-section" key={group.id}>
|
||||
<div className="st-section-label">{t(`journey.studio.shapeGroup.${group.id}`)}</div>
|
||||
<div className="st-shape-grid">
|
||||
{group.shapes.map(shape => (
|
||||
<button type="button"
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => addShape(shape)}
|
||||
aria-label={shape}
|
||||
>
|
||||
<ShapeGlyph shape={shape} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'frames' && (
|
||||
<>
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.frameStyles')}</div>
|
||||
<div className="st-shape-grid">
|
||||
{([
|
||||
['none', 'plainFrame'],
|
||||
['polaroid', 'polaroidFrame'],
|
||||
['white', 'whiteFrame'],
|
||||
['shadow', 'shadowFrame'],
|
||||
['film', 'filmFrame'],
|
||||
['tape', 'tapeFrame'],
|
||||
] as [FrameStyle, string][]).map(([style, key]) => (
|
||||
{tab === 'icons' ? (
|
||||
<StudioIconShelf onPick={adders.addIcon} t={t} />
|
||||
) : tab === 'stickers' ? (
|
||||
<StudioStickersPanel page={page} t={t} />
|
||||
) : (
|
||||
<div className="st-panel-scroll">
|
||||
{tab === 'text' && <TextTab page={page} source={source} t={t} addText={adders.addText} />}
|
||||
{tab === 'shapes' && <ShapesTab addShape={adders.addShape} t={t} />}
|
||||
{tab === 'lines' && (
|
||||
<StudioLineShelf
|
||||
page={page}
|
||||
single={adders.single}
|
||||
onAdd={el => adders.addElement(adders.active, el)}
|
||||
t={t}
|
||||
lead={(
|
||||
<button type="button"
|
||||
key={style}
|
||||
className="st-shape-btn is-frame"
|
||||
onClick={() => addFrame(null, style)}
|
||||
title={t(`journey.studio.${key}`)}
|
||||
aria-label={t(`journey.studio.${key}`)}
|
||||
className="st-shape-btn"
|
||||
onClick={() => adders.addShape('line')}
|
||||
title={t('journey.studio.shapeKind.line')}
|
||||
aria-label={t('journey.studio.shapeKind.line')}
|
||||
>
|
||||
<FrameStyleGlyph style={style} />
|
||||
<Minus size={20} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.frameShapes')}</div>
|
||||
<div className="st-shape-grid">
|
||||
{FRAME_SHAPES.map(shape => (
|
||||
<button type="button"
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => addFrame(shape === 'rect' ? null : shape)}
|
||||
aria-label={shape}
|
||||
>
|
||||
<ShapeGlyph shape={shape} photo />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.frameHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'grids' && (
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.grids')}</div>
|
||||
<div className="st-grid-grid">
|
||||
{GRIDS.map(grid => (
|
||||
<button
|
||||
type="button"
|
||||
key={grid.id}
|
||||
className="st-grid-btn"
|
||||
onClick={() => {
|
||||
const els = gridElements(grid, defaultGridBox(page, single))
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) =>
|
||||
(i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
|
||||
}))
|
||||
// Selected as a group, so the block can be moved or resized
|
||||
// as one thing straight away rather than cell by cell.
|
||||
select(els.map(e => e.id))
|
||||
}}
|
||||
aria-label={grid.id}
|
||||
title={`${grid.cells.length}`}
|
||||
>
|
||||
{/* The arrangement itself: "hero-two" means nothing until you
|
||||
have seen it, and a diagram is faster to read than a name. */}
|
||||
<span className="st-grid-preview">
|
||||
{grid.cells.map((cell, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
left: `${cell.x * 100}%`,
|
||||
top: `${cell.y * 100}%`,
|
||||
width: `${cell.w * 100}%`,
|
||||
height: `${cell.h * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.gridHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'icons' && (
|
||||
<div className="st-section">
|
||||
{/*
|
||||
The search sits inside the scroller rather than above the tab
|
||||
strip, unlike the Content panel's: that one filters both of its
|
||||
tabs, this one belongs to a single tab, and hanging it above the
|
||||
strip would make the strip move when you switch away from it.
|
||||
*/}
|
||||
<div className="st-search" style={{ margin: '0 0 10px' }}>
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={iconQuery}
|
||||
onChange={e => { setIconQuery(e.target.value); setIconLimit(ICON_PAGE) }}
|
||||
placeholder={t('journey.studio.searchIcons')}
|
||||
aria-label={t('journey.studio.searchIcons')}
|
||||
spellCheck={false}
|
||||
)}
|
||||
/>
|
||||
{iconQuery && (
|
||||
<button type="button" onClick={() => { setIconQuery(''); setIconLimit(ICON_PAGE) }} aria-label={t('common.clear')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* The shelf, until somebody searches. */}
|
||||
{!iconQuery && (
|
||||
<>
|
||||
<div className="st-section-label">{t('journey.studio.iconsForTravel')}</div>
|
||||
<div className="st-shape-grid" style={{ marginBottom: 14 }}>
|
||||
{FEATURED_ICONS.map(name => (
|
||||
<IconCell key={name} name={name} onPick={addIcon} />
|
||||
))}
|
||||
</div>
|
||||
<div className="st-section-label">{t('journey.studio.iconsAll')}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="st-shape-grid">
|
||||
{shownIcons.map(name => (
|
||||
<IconCell key={name} name={name} onPick={addIcon} />
|
||||
))}
|
||||
</div>
|
||||
{/* What the observer below watches for. */}
|
||||
{shownIcons.length < matchedIcons.length && <div ref={iconTail} style={{ height: 1 }} />}
|
||||
{!matchedIcons.length && (
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.noMatches')}</p>
|
||||
)}
|
||||
{tab === 'frames' && <FramesTab adders={adders} t={t} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StudioTabPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,25 @@
|
||||
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared'
|
||||
import {
|
||||
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp,
|
||||
ChevronsDown, ChevronsUp, ClipboardCopy, Copy, Italic, Lock, Trash2, Unlock,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import type { BookElement, BookPageNumbers, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
|
||||
import { withThemeLinks } from '@trek/shared'
|
||||
import { ClipboardCopy, Copy, Lock, Trash2, Unlock } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { photoSrc } from './bookRender'
|
||||
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
|
||||
import { ShapeGlyph } from './StudioElementsPanel'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import '../../styles/studio-panels.css'
|
||||
import { PanelHead } from './StudioPanelHead'
|
||||
import { selectionTitle } from './studioLayerRows'
|
||||
import { StudioArrangeSection } from './StudioArrangeSection'
|
||||
import { TravelInspector } from './StudioTravelInspector'
|
||||
import { StudioStickerInspector } from './StudioStickerInspector'
|
||||
import { Swatches } from './StudioSwatches'
|
||||
import { StudioBackgroundSection } from './StudioBackgroundSection'
|
||||
import { StudioShapePaint } from './StudioShapePaint'
|
||||
import { MAX_BOOK_TEXT_SIZE, formatBookCoords, withThemeLinks } from '@trek/shared'
|
||||
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
|
||||
import { StudioFontPicker } from './StudioFontPicker'
|
||||
import { WeightLine } from './StudioTypeControls'
|
||||
import { italicTitle } from './fontPicker'
|
||||
import { PhotoCropExtras, PhotoLookSections } from './photo/StudioPhotoLook'
|
||||
import { StudioTextEffects } from './StudioTextEffects'
|
||||
import type { JourneySource } from './StudioSidebar'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { StudioArrangeSection } from './StudioArrangeSection'
|
||||
import { selectionTitle } from './studioLayerRows'
|
||||
import { EdgeSection } from './StudioEdgeControls'
|
||||
import { LineInspector } from './StudioLineInspector'
|
||||
import { StudioThemeLink } from './theme/StudioThemeLink'
|
||||
|
||||
/** How many icons the swap grid offers at once. A search narrows it below this. */
|
||||
const ICON_CHOICES = 60
|
||||
import { StudioDocumentInspector } from './inspector/StudioDocumentInspector'
|
||||
import { StudioPositionSection } from './inspector/StudioPositionSection'
|
||||
import { StudioTextInspector } from './inspector/StudioTextInspector'
|
||||
import { StudioPhotoInspector } from './inspector/StudioPhotoInspector'
|
||||
import { StudioShapeInspector } from './inspector/StudioShapeInspector'
|
||||
import { StudioIconInspector } from './inspector/StudioIconInspector'
|
||||
import { selectionKey } from './inspector/inspectorModel'
|
||||
import type { RailSection } from './sidebar/railSections'
|
||||
import type { JourneySource } from './StudioSidebar'
|
||||
|
||||
/**
|
||||
* Properties of whatever is selected.
|
||||
@@ -42,24 +31,27 @@ const ICON_CHOICES = 60
|
||||
* out reads as a settings dialog, not as a tool.
|
||||
*
|
||||
* The order is the same for every kind, because the panel is used by muscle
|
||||
* memory more than by reading: **what it says** first, then **how it looks**,
|
||||
* then **where it sits**, then **what is in front of what**. A photo's crop and
|
||||
* a mark's words are the same step in that sequence, so they sit in the same
|
||||
* place on the panel.
|
||||
* memory more than by reading:
|
||||
*
|
||||
* The frame's numbers are folded away by default. They are the least-used group
|
||||
* in the panel — dragging is how anyone actually positions an element — and
|
||||
* four number fields left open at the top pushed everything worth looking at
|
||||
* below the fold.
|
||||
* 1. **what it is and what it says**: a text's words, a photo's crop, a
|
||||
* shape's outline, a sticker's colours, a map's scope and framing;
|
||||
* 2. **where it sits**: the position and size, folded unless the element is
|
||||
* turned or faded (or is a sticker, which is turned as a matter of course),
|
||||
* and for several at once their angle and opacity without the frame;
|
||||
* 3. **how it looks**: type, colour, fill, frame;
|
||||
* 4. **the effects**: folded, and open on an element that uses them;
|
||||
* 5. **arrange**: lining up, stacking, grouping, the style clipboard.
|
||||
*
|
||||
* ── What is not here any more ────────────────────────────────────────────
|
||||
* Every group mounts afresh for each selection (inspector/inspectorModel.ts),
|
||||
* so a folded group opens for the element that uses it and closes again for
|
||||
* the next one, and nothing is remembered behind the person's back.
|
||||
*
|
||||
* Duplicate, lock and delete are in the panel head rather than in a section of
|
||||
* their own: they act on the element rather than describing it, and a group
|
||||
* called "Arrange" that held two of those next to the stacking buttons was the
|
||||
* one group in the panel whose name did not say what was inside it.
|
||||
* Duplicate, lock and delete are in the panel head rather than in a group of
|
||||
* their own: they act on the element rather than describing it.
|
||||
*/
|
||||
|
||||
type Set = (patch: Partial<BookElement>) => void
|
||||
|
||||
export function StudioInspector({
|
||||
spreadIndex,
|
||||
page,
|
||||
@@ -69,6 +61,7 @@ export function StudioInspector({
|
||||
setPageNumbers,
|
||||
t,
|
||||
locale,
|
||||
onOpenPanel,
|
||||
}: {
|
||||
spreadIndex: number
|
||||
page: BookPageSetup
|
||||
@@ -81,126 +74,35 @@ export function StudioInspector({
|
||||
source: JourneySource
|
||||
t: (k: string) => string
|
||||
locale: string
|
||||
/** Opens a panel of the rail on the other side: the Theme panel, or the Layers panel beside the selection. */
|
||||
onOpenPanel?: (section: RailSection) => void
|
||||
}) {
|
||||
const [iconQuery, setIconQuery] = useState('')
|
||||
/*
|
||||
* A page of results, memoised because the inspector re-renders on every
|
||||
* pointer move of a drag and the library is fourteen hundred names long.
|
||||
*/
|
||||
const iconChoices = useMemo(() => searchIcons(iconQuery).slice(0, ICON_CHOICES), [iconQuery])
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const selection = useStudioStore(s => s.selection)
|
||||
const update = useStudioStore(s => s.updateElement)
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const raise = useStudioStore(s => s.raise)
|
||||
const duplicate = useStudioStore(s => s.duplicate)
|
||||
const copy = useStudioStore(s => s.copy)
|
||||
const removeElements = useStudioStore(s => s.removeElements)
|
||||
|
||||
const spread = doc?.spreads[spreadIndex]
|
||||
const sel = spread?.elements.filter(e => selection.includes(e.id)) ?? []
|
||||
const el = sel[0]
|
||||
|
||||
if (!sel.length) {
|
||||
/*
|
||||
* Nothing selected, so this is where the *book's* own settings belong.
|
||||
* The panel was showing one line of hint text and nothing else, and page
|
||||
* numbers have nowhere else to go: they are not a property of any element,
|
||||
* because the number a page carries depends on where its spread sits.
|
||||
*/
|
||||
const folios = page.pageNumbers
|
||||
if (!el) {
|
||||
return (
|
||||
<aside className="st-panel st-inspector">
|
||||
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div>
|
||||
<div className="st-panel-scroll">
|
||||
<StudioBackgroundSection spreadIndex={spreadIndex} page={page} source={source} t={t} />
|
||||
|
||||
<Section label={t('journey.studio.pageNumbers')}>
|
||||
<Switch
|
||||
label={t('journey.studio.pageNumbers')}
|
||||
on={folios.show}
|
||||
onToggle={() => setPageNumbers({ show: !folios.show })}
|
||||
/>
|
||||
|
||||
{folios.show && (
|
||||
<>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.position')}>
|
||||
<Choice
|
||||
value={folios.position}
|
||||
options={(['outer', 'inner', 'centre'] as const).map(pos => ({
|
||||
value: pos, label: t(`journey.studio.folio.${pos}`),
|
||||
}))}
|
||||
onPick={position => setPageNumbers({ position })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.folioStart')}
|
||||
value={folios.startAt}
|
||||
min={0}
|
||||
max={9999}
|
||||
step={1}
|
||||
onChange={v => setPageNumbers({ startAt: Math.round(v) })}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.size')}
|
||||
value={folios.size}
|
||||
min={4}
|
||||
max={48}
|
||||
step={0.5}
|
||||
unit="pt"
|
||||
onChange={v => setPageNumbers({ size: v })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.folioMargin')}
|
||||
value={folios.margin}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={v => setPageNumbers({ margin: v })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Switch
|
||||
label={t('journey.studio.folioAuto')}
|
||||
on={folios.autoColor}
|
||||
onToggle={() => setPageNumbers({ autoColor: !folios.autoColor })}
|
||||
/>
|
||||
</div>
|
||||
{/* Picking a colour is the other way to turn automatic off, so
|
||||
the row stays reachable and simply reads as inactive. */}
|
||||
<div style={{ marginTop: 10, opacity: folios.autoColor ? 0.45 : 1 }}>
|
||||
<Line label={t('journey.studio.colour')} wide>
|
||||
<Swatches
|
||||
value={folios.color}
|
||||
onPick={c => setPageNumbers({ color: c, autoColor: false })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<p className="st-hint" style={{ paddingTop: 10 }}>
|
||||
{t('journey.studio.inspectorEmpty')}
|
||||
</p>
|
||||
<p className="st-hint" style={{ paddingTop: 6 }}>
|
||||
{t('journey.studio.pageHint')} {page.pageWidth} × {page.pageHeight} mm
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
<StudioDocumentInspector
|
||||
spreadIndex={spreadIndex}
|
||||
page={page}
|
||||
source={source}
|
||||
setPageNumbers={setPageNumbers}
|
||||
onOpenTheme={onOpenPanel && (() => onOpenPanel('theme'))}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const el = sel[0]
|
||||
const one = sel.length === 1
|
||||
|
||||
// A property edit is one undo step on its own; only drags open a gesture.
|
||||
const set = (patch: Partial<BookElement>) => commit(d => ({
|
||||
const set: Set = patch => commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
|
||||
...sp,
|
||||
@@ -209,581 +111,130 @@ export function StudioInspector({
|
||||
})),
|
||||
}))
|
||||
|
||||
const live = (patch: Partial<BookElement>) => update(spreadIndex, el.id, patch)
|
||||
|
||||
/**
|
||||
* Stretch the picture over the page it is on, or over both pages, bleed
|
||||
* included.
|
||||
*
|
||||
* One commit rather than `set` followed by `raise`: the frame, the rotation
|
||||
* put back to zero (a turned picture leaves bare corners), the cover crop and
|
||||
* the move to the back are one press, and one press comes back with one
|
||||
* undo. Which page is the one under the frame's centre; a cover has only
|
||||
* the one.
|
||||
*/
|
||||
const fill = (scope: 'page' | 'spread') => commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => {
|
||||
if (i !== spreadIndex) return sp
|
||||
const bleed = page.bleed
|
||||
const onRight = sp.role === 'inner' && scope === 'page'
|
||||
&& el.frame.x + el.frame.w / 2 >= page.pageWidth
|
||||
const frame = {
|
||||
x: onRight ? page.pageWidth - bleed : -bleed,
|
||||
y: -bleed,
|
||||
w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed,
|
||||
h: page.pageHeight + 2 * bleed,
|
||||
}
|
||||
const filled = { ...el, frame, rotation: 0, fit: 'cover' } as BookElement
|
||||
return { ...sp, elements: [filled, ...sp.elements.filter(e => e.id !== el.id)] }
|
||||
}),
|
||||
}))
|
||||
const live: Set = patch => update(spreadIndex, el.id, patch)
|
||||
|
||||
return (
|
||||
<aside className="st-panel st-inspector">
|
||||
{/*
|
||||
The head says what is selected and carries the three things you do TO
|
||||
it. It used to say only the kind, in the same small capitals as every
|
||||
section label below it, which left the panel with no top.
|
||||
The head says what is selected and carries the things you do TO it, in
|
||||
the head every panel opens on.
|
||||
*/}
|
||||
<div className="st-panel-head st-inspector-head">
|
||||
<span className="st-what">
|
||||
{one ? t(`journey.studio.kind.${el.kind}`) : selectionTitle(spread, selection, t)}
|
||||
{sel.length > 1 && <em>{sel.length}</em>}
|
||||
</span>
|
||||
<span className="st-head-acts">
|
||||
<Tooltip label={t('journey.studio.copyToPage')}>
|
||||
<button type="button"
|
||||
className="st-act"
|
||||
onClick={() => copy(spreadIndex, selection)}
|
||||
aria-label={t('journey.studio.copyToPage')}
|
||||
>
|
||||
<ClipboardCopy size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button type="button"
|
||||
className="st-act"
|
||||
onClick={() => duplicate(spreadIndex, selection)}
|
||||
title={t('journey.studio.duplicate')}
|
||||
aria-label={t('journey.studio.duplicate')}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className={`st-act ${el.locked ? 'is-on' : ''}`}
|
||||
onClick={() => set({ locked: !el.locked })}
|
||||
title={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
|
||||
aria-label={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
|
||||
aria-pressed={el.locked}
|
||||
>
|
||||
{el.locked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-act is-danger"
|
||||
onClick={() => removeElements(spreadIndex, selection)}
|
||||
title={t('journey.studio.delete')}
|
||||
aria-label={t('journey.studio.delete')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<PanelHead
|
||||
label={one ? t(`journey.studio.kind.${el.kind}`) : selectionTitle(spread, selection, t)}
|
||||
count={sel.length > 1 ? sel.length : undefined}
|
||||
actions={<SelectionActions spreadIndex={spreadIndex} el={el} selection={selection} set={set} t={t} />}
|
||||
/>
|
||||
|
||||
<div className="st-panel-scroll">
|
||||
{el.kind === 'text' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.text')}>
|
||||
<textarea
|
||||
className="st-input st-textarea"
|
||||
value={el.text}
|
||||
rows={5}
|
||||
onChange={e => live({ text: e.target.value, overridden: true })}
|
||||
onBlur={e => set({ text: e.target.value, overridden: true })}
|
||||
/>
|
||||
{el.binding && !el.overridden && (
|
||||
<p className="st-hint" style={{ paddingTop: 6 }}>{t('journey.studio.boundHint')}</p>
|
||||
)}
|
||||
{/*
|
||||
How a point is set. Only a coordinate binding has the question,
|
||||
and answering it re-sets the words on the spot rather than at
|
||||
the next open — switching a notation and seeing nothing happen
|
||||
reads as a broken control. Deliberately not `overridden`:
|
||||
choosing a format is not writing the text.
|
||||
*/}
|
||||
{el.binding?.source === 'entry.location' && el.binding.format && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.coordsMark')}>
|
||||
<Choice
|
||||
value={el.binding.format}
|
||||
options={[
|
||||
{ value: 'dms' as const, label: t('journey.studio.coordsDms') },
|
||||
{ value: 'decimal' as const, label: t('journey.studio.coordsDecimal') },
|
||||
]}
|
||||
onPick={f => {
|
||||
const entry = source.entries.find(e => e.id === el.binding!.entryId)
|
||||
const text = entry?.lat != null && entry.lng != null
|
||||
? formatBookCoords(entry.lat, entry.lng, f)
|
||||
: el.text
|
||||
set({ binding: { ...el.binding!, format: f }, text })
|
||||
}}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.typography')}>
|
||||
{/*
|
||||
Each family set in itself, because a list of names tells you
|
||||
nothing about a typeface — and grouped, because "which serif"
|
||||
is a different question from "serif or sans".
|
||||
*/}
|
||||
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch)} t={t} />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<WeightLine font={el.font} weight={el.weight} onPick={weight => set({ weight })} t={t} />
|
||||
<Line label={t('journey.studio.align')}>
|
||||
<div className="st-row">
|
||||
{([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
|
||||
<button type="button"
|
||||
key={a}
|
||||
className={`st-chip is-icon ${el.align === a ? 'is-on' : ''}`}
|
||||
onClick={() => set({ align: a })}
|
||||
aria-label={a}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
))}
|
||||
{/* Italic sits with the alignment because both are things
|
||||
you do to a line rather than to a typeface. It had no
|
||||
control at all before, though the renderer drew it. */}
|
||||
<button type="button"
|
||||
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
|
||||
onClick={() => set({ italic: !el.italic })}
|
||||
title={italicTitle(el.font, el.weight, t)}
|
||||
aria-label={t('journey.studio.italic')}
|
||||
aria-pressed={el.italic}
|
||||
>
|
||||
<Italic size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Line>
|
||||
</div>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<NumField label={t('journey.studio.size')} value={el.size} min={4} max={MAX_BOOK_TEXT_SIZE} step={0.5} unit="pt" onChange={v => set({ size: v })} />
|
||||
<NumField label={t('journey.studio.leading')} value={el.leading} min={0.7} max={3} step={0.05} onChange={v => set({ leading: v })} />
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{/*
|
||||
Letter spacing was rendered but had no control, so type the
|
||||
layout had tracked out could be edited in every way except the
|
||||
one that made it look like that.
|
||||
*/}
|
||||
<NumField
|
||||
label={t('journey.studio.tracking')}
|
||||
value={el.tracking}
|
||||
min={-0.2}
|
||||
max={1.5}
|
||||
step={0.01}
|
||||
unit="em"
|
||||
onChange={v => set({ tracking: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
<Line label={t('journey.studio.text')} wide>
|
||||
<Swatches value={el.color} onPick={c => set({ color: c })} />
|
||||
<StudioThemeLink theme={doc?.theme} token={el.token} t={t}
|
||||
onLink={(token, color) => set({ color, token })} onUnlink={() => set({ token: undefined })} />
|
||||
</Line>
|
||||
</Section>
|
||||
|
||||
<StudioTextEffects el={el} set={set} live={live} photos={source.photos} spread={spread} t={t} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.kind === 'photo' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.crop')}>
|
||||
<Line label={t('journey.studio.crop')} wide>
|
||||
<Choice
|
||||
value={el.fit}
|
||||
options={(['cover', 'contain'] as const).map(f => ({
|
||||
value: f, label: t(`journey.studio.fit.${f}`),
|
||||
}))}
|
||||
onPick={fit => set({ fit })}
|
||||
/>
|
||||
</Line>
|
||||
{/*
|
||||
An empty frame has no picture to aim at, and asking for one by
|
||||
id produced a request for /api/photos/null/thumbnail and a
|
||||
broken image under a caption telling you to drag it.
|
||||
*/}
|
||||
{el.photoId != null ? (
|
||||
<FocalPad
|
||||
photoId={el.photoId}
|
||||
x={el.focalX}
|
||||
y={el.focalY}
|
||||
onDrag={(x, y) => live({ focalX: x, focalY: y })}
|
||||
onCommit={(x, y) => set({ focalX: x, focalY: y })}
|
||||
hint={t('journey.studio.focalHint')}
|
||||
/>
|
||||
) : (
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.emptyFrame')}</p>
|
||||
)}
|
||||
<PhotoCropExtras el={el} set={set} live={live} t={t} />
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.look')}>
|
||||
<Line label={t('journey.studio.look')} wide>
|
||||
<Choice
|
||||
value={el.filter}
|
||||
options={(['none', 'bw', 'warm', 'cool', 'fade', 'contrast'] as const).map(f => ({
|
||||
value: f, label: t(`journey.studio.filter.${f}`),
|
||||
}))}
|
||||
onPick={filter => set({ filter })}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.frameStyle')} wide>
|
||||
<Choice
|
||||
value={el.frameStyle}
|
||||
options={([
|
||||
['none', 'plainFrame'],
|
||||
['polaroid', 'polaroidFrame'],
|
||||
['white', 'whiteFrame'],
|
||||
['shadow', 'shadowFrame'],
|
||||
['film', 'filmFrame'],
|
||||
['tape', 'tapeFrame'],
|
||||
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
|
||||
onPick={frameStyle => set({ frameStyle })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
{/* A corner radius and a mask are the same idea at different
|
||||
resolutions, so the radius steps aside once a shape is cut.
|
||||
It did not, and edited a property the renderer ignored. */}
|
||||
{(!el.mask || el.mask === 'rect') && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.radius')}
|
||||
value={el.radius}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={v => set({ radius: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<PhotoLookSections el={el} set={set} live={live} t={t} />
|
||||
|
||||
<Section label={t('journey.studio.mask')} defaultOpen={false}>
|
||||
<div className="st-mini-shapes">
|
||||
<button type="button"
|
||||
className={`st-mini-shape ${el.mask ? '' : 'is-on'}`}
|
||||
onClick={() => set({ mask: null })}
|
||||
title={t('journey.studio.maskNone')}
|
||||
>
|
||||
<span className="st-mini-none" />
|
||||
</button>
|
||||
{FRAME_SHAPES.filter(sh => sh !== 'rect').map(sh => (
|
||||
<button type="button"
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ mask: sh })}
|
||||
aria-label={sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<EdgeSection
|
||||
edge={el.edge}
|
||||
edgeSize={el.edgeSize}
|
||||
outline={el.mask && el.mask !== 'rect' ? el.mask : null}
|
||||
onSet={patch => set(patch)}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.kind === 'shape' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.shape')}>
|
||||
{/* Every shape, not just the two the element started as: swapping
|
||||
a placed shape keeps its position, size and colour, which
|
||||
deleting and re-adding would not. */}
|
||||
<div className="st-mini-shapes">
|
||||
{SHAPE_GROUPS.flatMap(g => g.shapes).map((sh: BookShapeId) => (
|
||||
<button type="button"
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ shape: sh })}
|
||||
aria-label={sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{/* A photo had a corner radius and a shape did not, although
|
||||
both store the same field and the renderer draws both. */}
|
||||
<NumField
|
||||
label={t('journey.studio.radius')}
|
||||
value={el.radius}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={v => set({ radius: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<StudioShapePaint
|
||||
el={el}
|
||||
spreadIndex={spreadIndex}
|
||||
selection={selection}
|
||||
onStroke={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
|
||||
theme={doc?.theme}
|
||||
set={set}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<EdgeSection edge={el.edge} edgeSize={el.edgeSize} outline={el.shape} onSet={patch => set(patch)} t={t} />
|
||||
|
||||
<Section label={t('journey.studio.strokeStyle')} defaultOpen={false}>
|
||||
<Line label={t('journey.studio.strokeStyle')} wide>
|
||||
<Choice
|
||||
value={el.strokeStyle}
|
||||
options={([
|
||||
['solid', 'strokeSolid'],
|
||||
['dashed', 'strokeDashed'],
|
||||
['dotted', 'strokeDotted'],
|
||||
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
|
||||
onPick={strokeStyle => set({
|
||||
strokeStyle,
|
||||
// A stroke style with no stroke is invisible, so asking for
|
||||
// dashes turns the outline on rather than doing nothing.
|
||||
...(el.stroke ? {} : { stroke: el.fill ?? '#141414', strokeWidth: el.strokeWidth || 0.5 }),
|
||||
})}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.strokeWidth')}
|
||||
value={el.strokeWidth}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.1}
|
||||
unit="mm"
|
||||
onChange={v => set({ strokeWidth: v, ...(v > 0 && !el.stroke ? { stroke: '#141414' } : {}) })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.kind === 'icon' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.icon')}>
|
||||
{/*
|
||||
A search rather than the whole library: the picker in the panel
|
||||
can afford to grow as it is scrolled, but the inspector is a
|
||||
column beside a page and its job here is swapping one drawing
|
||||
for a near neighbour. Swapping keeps the position, size and
|
||||
colour that deleting and re-placing would lose.
|
||||
*/}
|
||||
<input
|
||||
className="st-input"
|
||||
value={iconQuery}
|
||||
onChange={e => setIconQuery(e.target.value)}
|
||||
placeholder={t('journey.studio.searchIcons')}
|
||||
aria-label={t('journey.studio.searchIcons')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="st-mini-shapes" style={{ marginTop: 8 }}>
|
||||
{iconChoices.map(name => {
|
||||
const Glyph = iconComponent(name)
|
||||
return (
|
||||
<button type="button"
|
||||
key={name}
|
||||
className={`st-mini-shape ${el.name === name ? 'is-on' : ''}`}
|
||||
onClick={() => set({ name })}
|
||||
aria-label={name}
|
||||
title={iconLabel(name)}
|
||||
>
|
||||
<Glyph strokeWidth={1.7} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
<Line label={t('journey.studio.icon')} wide>
|
||||
<Swatches value={el.color} onPick={c => set({ color: c })} />
|
||||
<StudioThemeLink theme={doc?.theme} token={el.token} t={t}
|
||||
onLink={(token, color) => set({ color, token })} onUnlink={() => set({ token: undefined })} />
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.lineWidth')}
|
||||
value={el.lineWidth}
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.25}
|
||||
onChange={v => set({ lineWidth: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.kind === 'sticker' && <StudioStickerInspector el={el} set={set} t={t} />}
|
||||
{el.kind === 'line' && <LineInspector el={el} spreadIndex={spreadIndex} t={t} />}
|
||||
|
||||
<TravelInspector el={el} stats={stats} path={path} set={set} t={t} locale={locale} />
|
||||
|
||||
{/*
|
||||
Where it sits, folded away.
|
||||
|
||||
Everything in here can be done by dragging, which is how it is
|
||||
actually done; the numbers are for the one time in ten you need the
|
||||
same margin on two pages. Opacity and rotation live here too — both
|
||||
were painted by the renderer and neither had a control anywhere.
|
||||
*/}
|
||||
{one && (
|
||||
<Section label={t('journey.studio.position')} defaultOpen={false}>
|
||||
<div className="st-grid2">
|
||||
<NumField label="X" value={el.frame.x} unit="mm" onChange={v => set({ frame: { ...el.frame, x: v } })} />
|
||||
<NumField label="Y" value={el.frame.y} unit="mm" onChange={v => set({ frame: { ...el.frame, y: v } })} />
|
||||
<NumField label={t('journey.studio.width')} value={el.frame.w} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, w: v } })} />
|
||||
<NumField label={t('journey.studio.height')} value={el.frame.h} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, h: v } })} />
|
||||
</div>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.rotation')}
|
||||
value={el.rotation}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
unit="°"
|
||||
onChange={v => set({ rotation: v })}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.opacity')}
|
||||
value={Math.round(el.opacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
unit="%"
|
||||
onChange={v => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<StudioArrangeSection spreadIndex={spreadIndex} t={t} />
|
||||
|
||||
<Section label={t('journey.studio.arrange')} defaultOpen={false}>
|
||||
<div className="st-row">
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'front')} title={t('journey.studio.toFront')}>
|
||||
<ChevronsUp size={14} />
|
||||
</button>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'up')} title={t('journey.studio.forward')}>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'down')} title={t('journey.studio.backward')}>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'back')} title={t('journey.studio.toBack')}>
|
||||
<ChevronsDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/*
|
||||
The picture as the page.
|
||||
|
||||
A full-bleed photograph is the commonest layout in any photo book
|
||||
and the hardest to make by hand: the frame has to reach exactly to
|
||||
the bleed on every side, and a millimetre short leaves a white
|
||||
hairline on the trimmed page. So it is one press. It goes to the
|
||||
back as well, since a page-sized picture in front of everything is
|
||||
a page with nothing else on it.
|
||||
*/}
|
||||
{one && el.kind === 'photo' && (
|
||||
<div className="st-row" style={{ marginTop: 6 }}>
|
||||
<button type="button" className="st-chip" onClick={() => fill('page')} title={t('journey.studio.fillHint')}>
|
||||
{t('journey.studio.fillPage')}
|
||||
</button>
|
||||
{spread.role === 'inner' && (
|
||||
<button type="button" className="st-chip" onClick={() => fill('spread')} title={t('journey.studio.fillHint')}>
|
||||
{t('journey.studio.fillSpread')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
<div className="st-panel-scroll" key={selectionKey(selection)}>
|
||||
<KindInspector
|
||||
el={el}
|
||||
one={one}
|
||||
spreadIndex={spreadIndex}
|
||||
spread={spread}
|
||||
page={page}
|
||||
selection={selection}
|
||||
stats={stats}
|
||||
path={path}
|
||||
source={source}
|
||||
set={set}
|
||||
live={live}
|
||||
position={<StudioPositionSection el={el} sel={sel} set={set} t={t} />}
|
||||
t={t}
|
||||
locale={locale}
|
||||
/>
|
||||
<StudioArrangeSection
|
||||
spreadIndex={spreadIndex}
|
||||
t={t}
|
||||
onShowLayers={onOpenPanel && (() => onOpenPanel('layers'))}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the picture is anchored inside its frame.
|
||||
*
|
||||
* Two sliders were the first draft and they were the wrong instrument: a focal
|
||||
* point is one position in a plane, not two unrelated numbers, and nobody can
|
||||
* predict what "X 0.62" does to a photograph. Dragging the point over the actual
|
||||
* image answers the question by showing it.
|
||||
*/
|
||||
export function FocalPad({
|
||||
photoId, x, y, onDrag, onCommit, hint,
|
||||
}: {
|
||||
photoId: number
|
||||
x: number
|
||||
y: number
|
||||
onDrag: (x: number, y: number) => void
|
||||
onCommit: (x: number, y: number) => void
|
||||
hint: string
|
||||
/** Copy to another page, duplicate, lock and delete: what you do to the selection, in the head. */
|
||||
function SelectionActions({ spreadIndex, el, selection, set, t }: {
|
||||
spreadIndex: number
|
||||
el: BookElement
|
||||
selection: string[]
|
||||
set: Set
|
||||
t: (k: string) => string
|
||||
}) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const last = useRef({ x, y })
|
||||
|
||||
const from = (e: React.PointerEvent) => {
|
||||
const r = box.current!.getBoundingClientRect()
|
||||
const nx = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
|
||||
const ny = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))
|
||||
last.current = { x: nx, y: ny }
|
||||
return last.current
|
||||
}
|
||||
|
||||
const duplicate = useStudioStore(s => s.duplicate)
|
||||
const copy = useStudioStore(s => s.copy)
|
||||
const removeElements = useStudioStore(s => s.removeElements)
|
||||
const lockLabel = t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')
|
||||
return (
|
||||
<div className="st-focal-wrap">
|
||||
<div
|
||||
className="st-focal"
|
||||
ref={box}
|
||||
onPointerDown={e => {
|
||||
(e.target as Element).setPointerCapture(e.pointerId)
|
||||
const p = from(e)
|
||||
onDrag(p.x, p.y)
|
||||
}}
|
||||
onPointerMove={e => {
|
||||
if (!e.currentTarget.hasPointerCapture?.(e.pointerId)) return
|
||||
const p = from(e)
|
||||
onDrag(p.x, p.y)
|
||||
}}
|
||||
onPointerUp={() => onCommit(last.current.x, last.current.y)}
|
||||
>
|
||||
<img src={photoSrc(photoId, false)} alt="" draggable={false} />
|
||||
<span className="st-focal-dot" style={{ left: `${x * 100}%`, top: `${y * 100}%` }} />
|
||||
</div>
|
||||
<p className="st-hint" style={{ padding: '6px 0 0' }}>{hint}</p>
|
||||
</div>
|
||||
<>
|
||||
<Tooltip label={t('journey.studio.copyToPage')}>
|
||||
<button type="button" className="st-act" onClick={() => copy(spreadIndex, selection)}
|
||||
aria-label={t('journey.studio.copyToPage')}>
|
||||
<ClipboardCopy size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button type="button" className="st-act" onClick={() => duplicate(spreadIndex, selection)}
|
||||
title={t('journey.studio.duplicate')} aria-label={t('journey.studio.duplicate')}>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button type="button" className={`st-act ${el.locked ? 'is-on' : ''}`} onClick={() => set({ locked: !el.locked })}
|
||||
title={lockLabel} aria-label={lockLabel} aria-pressed={el.locked}>
|
||||
{el.locked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
</button>
|
||||
<button type="button" className="st-act is-danger" onClick={() => removeElements(spreadIndex, selection)}
|
||||
title={t('journey.studio.delete')} aria-label={t('journey.studio.delete')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The groups of the selected kind, each placing the position group after what the element is. */
|
||||
function KindInspector({ el, one, spreadIndex, spread, page, selection, stats, path, source, set, live, position, t, locale }: {
|
||||
el: BookElement
|
||||
one: boolean
|
||||
spreadIndex: number
|
||||
spread: BookSpread | undefined
|
||||
page: BookPageSetup
|
||||
selection: string[]
|
||||
stats: JourneyStats | null
|
||||
path: [number, number][][]
|
||||
source: JourneySource
|
||||
set: Set
|
||||
live: Set
|
||||
position: ReactNode
|
||||
t: (k: string) => string
|
||||
locale: string
|
||||
}) {
|
||||
const theme = useStudioStore(s => s.doc?.theme)
|
||||
switch (el.kind) {
|
||||
case 'text':
|
||||
return (
|
||||
<StudioTextInspector el={el} set={set} live={live} source={source} spread={spread} theme={theme}
|
||||
position={position} t={t} />
|
||||
)
|
||||
case 'photo':
|
||||
return (
|
||||
<StudioPhotoInspector el={el} one={one} spreadIndex={spreadIndex} page={page} set={set} live={live}
|
||||
position={position} t={t} />
|
||||
)
|
||||
case 'shape':
|
||||
return (
|
||||
<StudioShapeInspector el={el} spreadIndex={spreadIndex} selection={selection} theme={theme} set={set}
|
||||
position={position} t={t} />
|
||||
)
|
||||
case 'icon':
|
||||
return <StudioIconInspector el={el} theme={theme} set={set} position={position} t={t} />
|
||||
case 'sticker':
|
||||
return <StudioStickerInspector el={el} set={set} position={position} t={t} />
|
||||
case 'line':
|
||||
return <LineInspector el={el} spreadIndex={spreadIndex} position={position} t={t} />
|
||||
case 'map':
|
||||
case 'stats':
|
||||
case 'countries':
|
||||
case 'badge':
|
||||
case 'list':
|
||||
return <TravelInspector el={el} stats={stats} path={path} set={set} t={t} locale={locale} position={position} />
|
||||
default:
|
||||
return <>{position}</>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,8 @@ describe('the align section', () => {
|
||||
store().select(['a']);
|
||||
render(<StudioArrangeSection spreadIndex={0} t={t} />);
|
||||
// Folded for one element: lining up is not what one element usually needs next.
|
||||
fireEvent.click(screen.getByText('journey.studio.layers.align'));
|
||||
expect(screen.queryByLabelText('journey.studio.layers.alignRight')).toBeNull();
|
||||
fireEvent.click(screen.getByText('journey.studio.arrange'));
|
||||
expect(screen.getByRole('radio', { name: 'journey.studio.layers.toSelection' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByLabelText('journey.studio.layers.alignRight'));
|
||||
expect(el('a').frame.x).toBe(190);
|
||||
|
||||
@@ -367,7 +367,8 @@ function RowName(p: {
|
||||
return (
|
||||
<span
|
||||
className={`st-layer-name ${p.named ? '' : 'is-auto'}`}
|
||||
title={p.t('journey.studio.layers.rename')}
|
||||
// One line is a must in a tree of rows, so a long name is cut there and read whole here.
|
||||
title={p.label}
|
||||
onDoubleClick={own(p.onRename)}
|
||||
>
|
||||
{p.label}
|
||||
|
||||
@@ -7,17 +7,21 @@ import {
|
||||
bookLineStyled,
|
||||
} from '@trek/shared';
|
||||
import { ArrowLeftRight } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { ornamentInUse } from './inspector/inspectorModel';
|
||||
import { lineGeometry } from './lineRender';
|
||||
import { LinePaths } from './LineView';
|
||||
import { Choice, Line, NumField, Section } from './StudioControls';
|
||||
import { STROKE_STYLE_OPTIONS } from './studioOptions';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
import { lineGeometry } from './lineRender';
|
||||
|
||||
/**
|
||||
* The inspector of a drawn line: its colour and weight, dashes, the course it
|
||||
* takes, a wave or a zigzag, the hand-drawn wobble, what sits on each end and
|
||||
* in the middle.
|
||||
* The inspector of a drawn line: the course it takes first (straight or
|
||||
* smooth, a wave or a zigzag, the hand-drawn wobble), then where it sits, then
|
||||
* its look (colour, weight, dashes) and what sits on each end, and folded
|
||||
* under those the ornament in its middle, open on a line that has one.
|
||||
*
|
||||
* Every change goes through `bookLineStyled`, the book engine's own restyle,
|
||||
* so a wider stroke or a new head refits the frame exactly as the assistant's
|
||||
@@ -66,10 +70,13 @@ const OrnamentGlyph = ({ ornament }: { ornament: BookLineOrnament }) => (
|
||||
export function LineInspector({
|
||||
el,
|
||||
spreadIndex,
|
||||
position,
|
||||
t,
|
||||
}: {
|
||||
el: BookLineElement;
|
||||
spreadIndex: number;
|
||||
/** The position and size group, which every kind places after what it is. */
|
||||
position: ReactNode;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const commit = useStudioStore((s) => s.commit);
|
||||
@@ -119,19 +126,29 @@ export function LineInspector({
|
||||
return (
|
||||
<>
|
||||
<Section label={t('journey.studio.kind.line')}>
|
||||
<Line label={t('journey.studio.stroke')} wide>
|
||||
<Swatches value={el.stroke} onPick={(c) => set({ stroke: c })} />
|
||||
</Line>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.strokeWidth')}
|
||||
value={el.strokeWidth}
|
||||
min={0.05}
|
||||
max={20}
|
||||
step={0.05}
|
||||
unit="mm"
|
||||
onChange={(v) => set({ strokeWidth: v })}
|
||||
<Line label={t('journey.studio.lineCourseTitle')} wide>
|
||||
<Choice
|
||||
value={el.curve}
|
||||
ariaLabel={t('journey.studio.lineCourseTitle')}
|
||||
options={(['straight', 'smooth'] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`journey.studio.lineCourse.${value}`),
|
||||
}))}
|
||||
onPick={(curve) => set({ curve })}
|
||||
/>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.lineBodyTitle')} wide>
|
||||
<Choice
|
||||
value={el.squiggle}
|
||||
ariaLabel={t('journey.studio.lineBodyTitle')}
|
||||
options={(['none', 'wave', 'zigzag'] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`journey.studio.lineBody.${value}`),
|
||||
}))}
|
||||
onPick={(squiggle) => set({ squiggle })}
|
||||
/>
|
||||
</Line>
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.wobble')}
|
||||
value={Math.round(el.wobble * 100)}
|
||||
@@ -142,42 +159,34 @@ export function LineInspector({
|
||||
onChange={(v) => set({ wobble: Math.min(1, Math.max(0, v / 100)) })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.strokeStyle')} wide>
|
||||
<p className="st-hint st-hint-after">{t('journey.studio.linePointsHint')}</p>
|
||||
</Section>
|
||||
|
||||
{position}
|
||||
|
||||
<Section label={t('journey.studio.look')}>
|
||||
<Swatches value={el.stroke} onPick={(c) => set({ stroke: c })} />
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.strokeWidth')}
|
||||
value={el.strokeWidth}
|
||||
min={0.05}
|
||||
max={20}
|
||||
step={0.05}
|
||||
unit="mm"
|
||||
onChange={(v) => set({ strokeWidth: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="st-section-gap">
|
||||
<Line label={t('journey.studio.strokeStyle')}>
|
||||
<Choice
|
||||
value={el.dash}
|
||||
options={[
|
||||
{ value: 'solid' as const, label: t('journey.studio.strokeSolid') },
|
||||
{ value: 'dashed' as const, label: t('journey.studio.strokeDashed') },
|
||||
{ value: 'dotted' as const, label: t('journey.studio.strokeDotted') },
|
||||
]}
|
||||
ariaLabel={t('journey.studio.strokeStyle')}
|
||||
options={STROKE_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
onPick={(dash) => set({ dash })}
|
||||
/>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.lineCourseTitle')} wide>
|
||||
<Choice
|
||||
value={el.curve}
|
||||
options={(['straight', 'smooth'] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`journey.studio.lineCourse.${value}`),
|
||||
}))}
|
||||
onPick={(curve) => set({ curve })}
|
||||
/>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.lineBodyTitle')} wide>
|
||||
<Choice
|
||||
value={el.squiggle}
|
||||
options={(['none', 'wave', 'zigzag'] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`journey.studio.lineBody.${value}`),
|
||||
}))}
|
||||
onPick={(squiggle) => set({ squiggle })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>
|
||||
{t('journey.studio.linePointsHint')}
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.lineEnds')}>
|
||||
@@ -187,7 +196,7 @@ export function LineInspector({
|
||||
<Line label={t('journey.studio.lineEnd')} wide>
|
||||
<Choice value={el.endCap} options={caps(false)} onPick={(endCap) => set({ endCap })} />
|
||||
</Line>
|
||||
<div className="st-row" style={{ marginTop: 6 }}>
|
||||
<div className="st-row st-section-gap">
|
||||
<button type="button" className="st-chip" onClick={swapEnds}>
|
||||
<ArrowLeftRight size={13} />
|
||||
{t('journey.studio.swapEnds')}
|
||||
@@ -195,7 +204,7 @@ export function LineInspector({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.ornamentTitle')} defaultOpen={el.ornament !== 'none'}>
|
||||
<Section label={t('journey.studio.ornamentTitle')} defaultOpen={ornamentInUse(el)}>
|
||||
<Choice
|
||||
value={el.ornament}
|
||||
options={BOOK_LINE_ORNAMENTS.map((o) => ({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type BookElement, type BookPageSetup, bookElementId, bookNewLine } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { LinePaths } from './LineView';
|
||||
import { Shelf } from './StudioPanelHead';
|
||||
import { DIVIDER_PRESETS, LINE_PRESETS, type LinePreset as Preset, presetPoints } from './linePresets';
|
||||
import { lineGeometry } from './lineRender';
|
||||
|
||||
@@ -39,11 +41,14 @@ export function StudioLineShelf({
|
||||
single,
|
||||
onAdd,
|
||||
t,
|
||||
lead,
|
||||
}: {
|
||||
page: BookPageSetup;
|
||||
single: boolean;
|
||||
onAdd: (el: BookElement) => void;
|
||||
t: (k: string) => string;
|
||||
/** A tile before the drawn lines: the Elements panel puts its plain rule there. */
|
||||
lead?: ReactNode;
|
||||
}) {
|
||||
const add = (preset: Preset) => {
|
||||
const origin = single ? 0 : page.pageWidth;
|
||||
@@ -52,8 +57,9 @@ export function StudioLineShelf({
|
||||
const points = presetPoints(preset, x, page.pageHeight / 2, w);
|
||||
onAdd(bookNewLine({ ...preset.look, points }, { id: bookElementId('l'), frame: null, inner: !single, page }));
|
||||
};
|
||||
const tiles = (list: readonly Preset[]) => (
|
||||
const tiles = (list: readonly Preset[], first?: ReactNode) => (
|
||||
<div className="st-shape-grid">
|
||||
{first}
|
||||
{list.map((p) => {
|
||||
const label = t(`journey.studio.linePreset.${p.id}`);
|
||||
return (
|
||||
@@ -73,14 +79,8 @@ export function StudioLineShelf({
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.drawnLines')}</div>
|
||||
{tiles(LINE_PRESETS)}
|
||||
</div>
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.dividers')}</div>
|
||||
{tiles(DIVIDER_PRESETS)}
|
||||
</div>
|
||||
<Shelf label={t('journey.studio.drawnLines')}>{tiles(LINE_PRESETS, lead)}</Shelf>
|
||||
<Shelf label={t('journey.studio.dividers')}>{tiles(DIVIDER_PRESETS)}</Shelf>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { PanelHead, Shelf } from './StudioPanelHead';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-HEAD-001 to FE-STUDIO-HEAD-003: the one head every Studio panel
|
||||
* opens on, and the shelf the panels on the left group their catalogues in.
|
||||
*/
|
||||
|
||||
describe('the panel head', () => {
|
||||
it('FE-STUDIO-HEAD-001: names the panel as a heading, with its count, a fact and its actions', () => {
|
||||
const { container } = render(
|
||||
<PanelHead label="Pages" count={7} meta="210 × 210 mm" actions={<button type="button">Act</button>} />
|
||||
);
|
||||
const heading = screen.getByRole('heading', { level: 2 });
|
||||
expect(heading.textContent).toBe('Pages7');
|
||||
expect(container.querySelector('.st-what-label')?.textContent).toBe('Pages');
|
||||
expect(container.querySelector('.st-what em')?.textContent).toBe('7');
|
||||
expect(container.querySelector('.st-head-meta')?.textContent).toBe('210 × 210 mm');
|
||||
expect(container.querySelector('.st-head-acts button')?.textContent).toBe('Act');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-002: leaves out what it is not given, and keeps a long name in a label that wraps', () => {
|
||||
const long = 'Gruppierung aufheben und Ausrichtung zurücksetzen';
|
||||
const { container } = render(<PanelHead label={long} />);
|
||||
expect(container.querySelector('.st-what em')).toBeNull();
|
||||
expect(container.querySelector('.st-head-meta')).toBeNull();
|
||||
expect(container.querySelector('.st-head-acts')).toBeNull();
|
||||
expect(container.querySelector('.st-what-label')?.textContent).toBe(long);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shelf', () => {
|
||||
it('FE-STUDIO-HEAD-003: heads its things with a label and closes with a hint, or holds them bare', () => {
|
||||
const { container, rerender } = render(
|
||||
<Shelf label="Stars" hint="Click one to add it.">
|
||||
<button type="button">Star</button>
|
||||
</Shelf>
|
||||
);
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Stars');
|
||||
expect(container.querySelector('.st-shelf > .st-shelf-hint')?.textContent).toBe('Click one to add it.');
|
||||
rerender(
|
||||
<Shelf>
|
||||
<button type="button">Star</button>
|
||||
</Shelf>
|
||||
);
|
||||
expect(screen.queryByRole('heading')).toBeNull();
|
||||
expect(container.querySelector('.st-shelf-hint')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Star' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,59 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* The bar at the top of a Studio panel: what it is, and how many.
|
||||
* The bar at the top of every Studio panel, on both sides of the page: what the
|
||||
* panel is about, how many of it there are, and the things you do to it.
|
||||
*
|
||||
* Its own file so the three panels can share it without any of them having to
|
||||
* import the others — `StudioSidebar` owns the rail, and a panel that reached
|
||||
* back into it for a header would make the sidebar impossible to split.
|
||||
* One head for all of them. The panels on the left used to open on small grey
|
||||
* capitals and the inspector on a darker title of its own, so the two sides of
|
||||
* the screen had two ideas of what a panel's top looks like, and the left ones
|
||||
* had none at all: their head was set exactly like every group label under it.
|
||||
* Now each panel opens on its name in ink, the count beside it as a quiet
|
||||
* figure, and the actions at the end. The head keeps one height whether it has
|
||||
* actions or not, so the heads of the two sides line up across the page.
|
||||
*
|
||||
* Its own file so every panel can share it without importing another panel:
|
||||
* `StudioSidebar` owns the rail, and a panel that reached back into it for a
|
||||
* header would make the sidebar impossible to split.
|
||||
*/
|
||||
export function PanelHead({ label, count }: { label: string; count?: number }) {
|
||||
export function PanelHead({ label, count, meta, actions }: {
|
||||
label: string
|
||||
count?: number
|
||||
/** A fact about the panel's subject in quiet type after the name, such as the page size. */
|
||||
meta?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="st-panel-head">
|
||||
<span>{label}</span>
|
||||
{count != null && <span style={{ fontVariantNumeric: 'tabular-nums' }}>{count}</span>}
|
||||
<h2 className="st-what">
|
||||
<span className="st-what-label">{label}</span>
|
||||
{count != null && <em>{count}</em>}
|
||||
</h2>
|
||||
{meta && <span className="st-head-meta">{meta}</span>}
|
||||
{actions && <span className="st-head-acts">{actions}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A titled group of things to add, in the panels on the left: a heading over a
|
||||
* grid or a list, and a line of help under it when it needs one.
|
||||
*
|
||||
* Never folded. A shelf is browsed, and a library whose groups had to be opened
|
||||
* one at a time would be a library read through a keyhole; the inspector's
|
||||
* groups fold because they are a reference, these are a catalogue.
|
||||
*/
|
||||
export function Shelf({ label, hint, children, className = '' }: {
|
||||
label?: string
|
||||
hint?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`st-section st-shelf ${className}`}>
|
||||
{label && <h3 className="st-section-label">{label}</h3>}
|
||||
{children}
|
||||
{hint && <p className="st-hint st-shelf-hint">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The search field of a Studio panel: the photos and entries of the journey,
|
||||
* the icon library, the stickers. One field, so the three look and behave
|
||||
* alike, the clear button included. It sits above the list it searches rather
|
||||
* than inside it, so it stays in reach while the list scrolls.
|
||||
*
|
||||
* A library with no tab of its own to count on (the icons, the stickers) gives
|
||||
* the field its count, and the field shows it while something is typed: how
|
||||
* many match is the first thing a search answers.
|
||||
*/
|
||||
export function StudioSearch({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
clearLabel,
|
||||
count,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** The placeholder and the field's accessible name. */
|
||||
label: string;
|
||||
clearLabel: string;
|
||||
/** How many the words match, shown beside them while there are any. */
|
||||
count?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="st-search">
|
||||
<Search size={14} aria-hidden />
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={label}
|
||||
aria-label={label}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{value.trim() !== '' && count != null && <output className="st-search-count">{count}</output>}
|
||||
{value && (
|
||||
<button type="button" onClick={() => onChange('')} aria-label={clearLabel}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
type BookShapeElement,
|
||||
type BookTheme,
|
||||
} from '@trek/shared';
|
||||
import { shapeOutlineInUse } from './inspector/inspectorModel';
|
||||
import { usePaintChannel } from './paint/usePaintChannel';
|
||||
import { Line, Section } from './StudioControls';
|
||||
import { Choice, Line, NumField, Section } from './StudioControls';
|
||||
import { STROKE_STYLE_OPTIONS } from './studioOptions';
|
||||
import { StudioPaintPicker } from './StudioPaintPicker';
|
||||
import type { StudioT } from './StudioSidebar';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
@@ -16,19 +18,20 @@ import { StudioThemeLink } from './theme/StudioThemeLink';
|
||||
/** The outline a shape is given when its fill goes and it has none, so it never turns invisible. */
|
||||
const OUTLINE = '#141414';
|
||||
|
||||
type SetShape = (patch: Partial<BookShapeElement>) => void;
|
||||
|
||||
/**
|
||||
* A shape's colours: its fill as a paint, and its outline.
|
||||
* A shape's fill, as a paint: none, one colour, a gradient or a pattern.
|
||||
*
|
||||
* The fill goes to every selected shape at once, the way the rest of the
|
||||
* inspector's properties do. Taking the fill away from a shape with no
|
||||
* outline gives it one, as the old fill switch did: a shape with neither is
|
||||
* a shape nobody can see or find again.
|
||||
*/
|
||||
export function StudioShapePaint({
|
||||
export function StudioShapeFill({
|
||||
el,
|
||||
spreadIndex,
|
||||
selection,
|
||||
onStroke,
|
||||
theme,
|
||||
set,
|
||||
t,
|
||||
@@ -36,11 +39,10 @@ export function StudioShapePaint({
|
||||
el: BookShapeElement;
|
||||
spreadIndex: number;
|
||||
selection: readonly string[];
|
||||
onStroke: (color: string) => void;
|
||||
/** The book's theme, for the theme colours under each row; none without one. */
|
||||
/** The book's theme, for the theme colours under the paint; none without one. */
|
||||
theme?: BookTheme;
|
||||
/** The inspector's write, for linking a colour to the theme. */
|
||||
set?: (patch: Partial<BookShapeElement>) => void;
|
||||
/** The inspector's write, for linking the fill to the theme. */
|
||||
set: SetShape;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const channel = usePaintChannel((paint) => (doc) => ({
|
||||
@@ -63,31 +65,75 @@ export function StudioShapePaint({
|
||||
}));
|
||||
|
||||
return (
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
<Line label={t('journey.studio.fill')} wide>
|
||||
<StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} />
|
||||
{set && (
|
||||
<StudioThemeLink
|
||||
theme={theme}
|
||||
token={el.token}
|
||||
t={t}
|
||||
onLink={(token, fill) => set({ fill, token, paint: undefined })}
|
||||
onUnlink={() => set({ token: undefined })}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
<Line label={t('journey.studio.stroke')} wide>
|
||||
<Swatches value={el.stroke ?? OUTLINE} onPick={onStroke} />
|
||||
{set && (
|
||||
<StudioThemeLink
|
||||
theme={theme}
|
||||
token={el.strokeToken}
|
||||
t={t}
|
||||
onLink={(strokeToken, stroke) => set({ stroke, strokeToken, strokeWidth: el.strokeWidth || 0.5 })}
|
||||
onUnlink={() => set({ strokeToken: undefined })}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
<Section label={t('journey.studio.fill')}>
|
||||
<StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} />
|
||||
<StudioThemeLink
|
||||
theme={theme}
|
||||
token={el.token}
|
||||
t={t}
|
||||
onLink={(token, fill) => set({ fill, token, paint: undefined })}
|
||||
onUnlink={() => set({ token: undefined })}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A shape's outline: its colour, the stroke it is drawn in and how wide.
|
||||
*
|
||||
* Folded until the shape has one, since most shapes are a fill and nothing
|
||||
* else. Every way of asking for an outline brings one: a colour, a width or a
|
||||
* dash on a shape that had none draws it rather than doing nothing.
|
||||
*/
|
||||
export function StudioShapeOutline({
|
||||
el,
|
||||
theme,
|
||||
set,
|
||||
t,
|
||||
}: {
|
||||
el: BookShapeElement;
|
||||
theme?: BookTheme;
|
||||
set: SetShape;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const width = el.strokeWidth || 0.5;
|
||||
return (
|
||||
<Section label={t('journey.studio.stroke')} defaultOpen={shapeOutlineInUse(el)}>
|
||||
<Swatches value={el.stroke ?? OUTLINE} onPick={(stroke) => set({ stroke, strokeWidth: width })} />
|
||||
<StudioThemeLink
|
||||
theme={theme}
|
||||
token={el.strokeToken}
|
||||
t={t}
|
||||
onLink={(strokeToken, stroke) => set({ stroke, strokeToken, strokeWidth: width })}
|
||||
onUnlink={() => set({ strokeToken: undefined })}
|
||||
/>
|
||||
<div className="st-section-gap">
|
||||
<Line label={t('journey.studio.strokeStyle')}>
|
||||
<Choice
|
||||
value={el.strokeStyle}
|
||||
ariaLabel={t('journey.studio.strokeStyle')}
|
||||
options={STROKE_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
onPick={(strokeStyle) =>
|
||||
set({
|
||||
strokeStyle,
|
||||
// A stroke style with no stroke is invisible, so asking for dashes turns the outline on.
|
||||
...(el.stroke ? {} : { stroke: el.fill ?? OUTLINE, strokeWidth: width }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
<div className="st-section-gap">
|
||||
<NumField
|
||||
label={t('journey.studio.strokeWidth')}
|
||||
value={el.strokeWidth}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.1}
|
||||
unit="mm"
|
||||
onChange={(v) => set({ strokeWidth: v, ...(v > 0 && !el.stroke ? { stroke: OUTLINE } : {}) })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
|
||||
import { foliosOf } from './bookSheets'
|
||||
import { StudioSidebar } from './StudioSidebar'
|
||||
import type { RailSection } from './sidebar/railSections'
|
||||
import { ProviderPicker } from '../Journey/JourneyDetailPageProviderPicker'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { StudioCanvas } from './StudioCanvas'
|
||||
@@ -46,6 +47,8 @@ export default function StudioShell() {
|
||||
const navigate = useNavigate()
|
||||
const [bookView, setBookView] = useState(true)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
// The rail's open section lives here, so the inspector can open the Theme or the Layers panel beside it.
|
||||
const [railSection, setRailSection] = useState<RailSection>('pages')
|
||||
const assistant = useStudioAssistant({ journeyId: s.journeyId, flush: s.flushBook })
|
||||
// The book's faces into the offline cache as it opens (bookFontWarm.ts).
|
||||
useWarmBookFaces()
|
||||
@@ -126,6 +129,8 @@ export default function StudioShell() {
|
||||
onToggleStop={s.setStopExcluded}
|
||||
providers={s.photoProviders}
|
||||
onBrowseProvider={s.browseProvider}
|
||||
section={railSection}
|
||||
onSection={setRailSection}
|
||||
/>
|
||||
<Workbench s={s} bookView={bookView} />
|
||||
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
|
||||
@@ -138,6 +143,7 @@ export default function StudioShell() {
|
||||
setPageNumbers={s.setPageNumbers}
|
||||
t={s.t}
|
||||
locale={s.locale}
|
||||
onOpenPanel={setRailSection}
|
||||
/>
|
||||
</StudioPaletteScope>
|
||||
</div>
|
||||
|
||||
@@ -1,46 +1,31 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Camera, ChevronDown, ImagePlus, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, Layers, LayoutTemplate,
|
||||
Palette, Plus, Search, Shapes, Sticker, Trash2, Upload, X,
|
||||
} from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { useElementSize } from '../../hooks/useElementSize'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { SpreadThumb } from './SpreadThumb'
|
||||
import { folioLabel, foliosOf } from './bookSheets'
|
||||
import { photoSrc } from './bookRender'
|
||||
import { bookElementId as uid } from '@trek/shared'
|
||||
import {
|
||||
bookCoordValue as coordValue, formatBookCoords, formatBookDate, type BookCoordFormat as CoordFormat,
|
||||
} from '@trek/shared'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { PanelHead as Head } from './StudioPanelHead'
|
||||
import { useState } from 'react'
|
||||
import type { BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { StudioElementsPanel } from './StudioElementsPanel'
|
||||
import { StudioStickersPanel } from './StudioStickersPanel'
|
||||
import { StudioLayersPanel } from './StudioLayersPanel'
|
||||
import { StudioTravelPanel } from './StudioTravelPanel'
|
||||
import { StudioThemePanel } from './theme/StudioThemePanel'
|
||||
import { StudioTemplatesPanel } from './templates/StudioTemplatesPanel'
|
||||
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
|
||||
import { StudioContentFilter } from './StudioContentFilter'
|
||||
import { emptyKeyFor, matchingEntries, matchingPhotos, photosFor, type PhotoFilter } from './photoFilter'
|
||||
import { StudioRail } from './sidebar/StudioRail'
|
||||
import { StudioPagesPanel } from './sidebar/StudioPagesPanel'
|
||||
import { StudioContentPanel } from './sidebar/StudioContentPanel'
|
||||
import { RAIL_IDS, type RailSection } from './sidebar/railSections'
|
||||
import type { StudioUploader } from './studioUpload'
|
||||
|
||||
/**
|
||||
* The left side of Studio: a narrow rail of sections and one wide panel showing
|
||||
* whichever is open.
|
||||
*
|
||||
* The shape is Canva's and it is the right one here for a plain reason — a book
|
||||
* has four completely different kinds of thing you reach for (which page, what
|
||||
* from the journey, what to draw, what arrangement), and putting them behind
|
||||
* tabs in a single 200px column would make each of them cramped. An icon rail
|
||||
* costs 52px and gives the panel the room a photo browser actually needs.
|
||||
* The shape is Canva's and it is the right one here for a plain reason: a book
|
||||
* has a handful of completely different kinds of thing you reach for (which
|
||||
* page, what from the journey, what arrangement, what to draw), and putting
|
||||
* them behind tabs in a single 200px column would make each of them cramped.
|
||||
* An icon rail costs 64px and gives the panel the room a photo browser needs.
|
||||
*
|
||||
* Which section is open is the shell's when it says so (`section` and
|
||||
* `onSection`), so the inspector on the other side can open the Theme or the
|
||||
* Layers panel; on its own the sidebar keeps it itself and opens on Pages.
|
||||
*/
|
||||
|
||||
type Section = 'pages' | 'content' | 'elements' | 'stickers' | 'travel' | 'templates' | 'layers' | 'theme'
|
||||
|
||||
export interface JourneySource {
|
||||
/**
|
||||
* The journey's title and subtitle, which a cover is set in when the book has
|
||||
@@ -74,29 +59,9 @@ export interface JourneySource {
|
||||
/** The translator, with the parameters some of the strings here take. */
|
||||
export type StudioT = (k: string, params?: Record<string, string | number>) => string
|
||||
|
||||
/**
|
||||
* How wide a page preview may be drawn.
|
||||
*
|
||||
* Page thumbnails and layout cards are drawn at a `scale()` rather than laid
|
||||
* out by CSS — the sheet inside them is sized in millimetres — so they need an
|
||||
* actual pixel width, and it is **measured** rather than derived.
|
||||
*
|
||||
* Deriving it was tried twice and was wrong twice. The panel width, the scroll
|
||||
* padding and the button's own border and padding are all knowable from
|
||||
* studio.css; the scrollbar is not. It appears only once the list is long
|
||||
* enough, takes a width that depends on the platform, and the arithmetic that
|
||||
* ignored it produced a card overhanging its own selection outline. Measuring
|
||||
* the row the cards sit in gets all four contributions at once and cannot drift
|
||||
* when any of them changes.
|
||||
*/
|
||||
const MIN_PREVIEW_W = 120
|
||||
|
||||
/** The button's border and padding, which the row's width still includes. */
|
||||
const THUMB_CHROME = (2 + 3) * 2
|
||||
|
||||
export function StudioSidebar({
|
||||
page, pxPerMm, bookView, source, stats, path, t, locale,
|
||||
canEdit, onUpload, onToggleStop, providers = [], onBrowseProvider,
|
||||
canEdit, onUpload, onToggleStop, providers = [], onBrowseProvider, section, onSection,
|
||||
}: {
|
||||
page: BookPageSetup
|
||||
pxPerMm: number
|
||||
@@ -118,46 +83,38 @@ export function StudioSidebar({
|
||||
providers?: { id: string; name: string }[]
|
||||
/** Opens the provider picker; answers the photo ids that landed, none when closed. */
|
||||
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>
|
||||
/** The open section, when the shell holds it. */
|
||||
section?: RailSection
|
||||
onSection?: (section: RailSection) => void
|
||||
}) {
|
||||
const [section, setSection] = useState<Section>('pages')
|
||||
|
||||
const SECTIONS: { id: Section; icon: typeof Files; labelKey: string }[] = [
|
||||
{ id: 'pages', icon: Files, labelKey: 'journey.studio.pages' },
|
||||
{ id: 'content', icon: ImageIcon, labelKey: 'journey.studio.content' },
|
||||
{ id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' },
|
||||
{ id: 'stickers', icon: Sticker, labelKey: 'journey.studio.stickers' },
|
||||
{ id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' },
|
||||
{ id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' },
|
||||
{ id: 'layers', icon: Layers, labelKey: 'journey.studio.layers' },
|
||||
{ id: 'theme', icon: Palette, labelKey: 'studio.theme.panel' },
|
||||
]
|
||||
const [own, setOwn] = useState<RailSection>('pages')
|
||||
const open = section ?? own
|
||||
const setOpen = (next: RailSection) => {
|
||||
setOwn(next)
|
||||
onSection?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="st-rail" aria-label={t('journey.studio.sections')}>
|
||||
{SECTIONS.map(sec => (
|
||||
<button type="button"
|
||||
key={sec.id}
|
||||
className={`st-rail-btn ${section === sec.id ? 'is-on' : ''}`}
|
||||
onClick={() => setSection(sec.id)}
|
||||
title={t(sec.labelKey)}
|
||||
aria-label={t(sec.labelKey)}
|
||||
>
|
||||
<sec.icon size={18} strokeWidth={1.7} />
|
||||
<span>{t(sec.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<StudioRail section={open} onSection={setOpen} t={t} />
|
||||
|
||||
<aside className="st-panel st-side">
|
||||
{section === 'pages' && <PagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
|
||||
{section === 'content' && (
|
||||
<ContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload}
|
||||
<aside
|
||||
className="st-panel st-side"
|
||||
id={RAIL_IDS.panel}
|
||||
role="tabpanel"
|
||||
aria-labelledby={RAIL_IDS.tab(open)}
|
||||
>
|
||||
{open === 'pages' && <StudioPagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
|
||||
{open === 'content' && (
|
||||
<StudioContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload}
|
||||
providers={providers} onBrowseProvider={onBrowseProvider} />
|
||||
)}
|
||||
{section === 'elements' && <StudioElementsPanel page={page} t={t} source={source} />}
|
||||
{section === 'stickers' && <StudioStickersPanel page={page} t={t} />}
|
||||
{section === 'travel' && (
|
||||
{open === 'templates' && (
|
||||
<StudioTemplatesPanel page={page} pxPerMm={pxPerMm} t={t} locale={locale} source={source} stats={stats}
|
||||
path={path} onOpenContent={() => setOpen('content')} />
|
||||
)}
|
||||
{open === 'elements' && <StudioElementsPanel page={page} t={t} source={source} />}
|
||||
{open === 'travel' && (
|
||||
<StudioTravelPanel
|
||||
page={page}
|
||||
stats={stats}
|
||||
@@ -168,618 +125,9 @@ export function StudioSidebar({
|
||||
onToggleStop={onToggleStop}
|
||||
/>
|
||||
)}
|
||||
{section === 'layers' && <StudioLayersPanel t={t} />}
|
||||
{section === 'theme' && <StudioThemePanel t={t} />}
|
||||
{section === 'templates' && (
|
||||
<StudioTemplatesPanel page={page} pxPerMm={pxPerMm} t={t} locale={locale} source={source} stats={stats}
|
||||
path={path} onOpenContent={() => setSection('content')} />
|
||||
)}
|
||||
{open === 'theme' && <StudioThemePanel t={t} />}
|
||||
{open === 'layers' && <StudioLayersPanel t={t} />}
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PagesPanel({
|
||||
page, pxPerMm, bookView, t,
|
||||
}: { page: BookPageSetup; pxPerMm: number; bookView: boolean; t: (k: string) => string }) {
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
// The page a chat card points at, outlined here (journey-studio.md 4.2).
|
||||
const hinted = useStudioStore(s => s.hintSpread)
|
||||
const setActive = useStudioStore(s => s.setActiveSpread)
|
||||
const addSpread = useStudioStore(s => s.addSpread)
|
||||
const insertSpread = useStudioStore(s => s.insertSpread)
|
||||
const duplicateSpread = useStudioStore(s => s.duplicateSpread)
|
||||
const removeSpread = useStudioStore(s => s.removeSpread)
|
||||
const moveSpread = useStudioStore(s => s.moveSpread)
|
||||
const canEditSpread = useStudioStore(s => s.canEditSpread)
|
||||
const spreads = doc?.spreads ?? []
|
||||
const list = useElementSize<HTMLDivElement>()
|
||||
const THUMB_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME)
|
||||
|
||||
/** Where a new spread would land: after the last inner one. */
|
||||
const lastInner = spreads.reduce((last, sp, i) => (sp.role === 'inner' ? i : last), -1)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [importError, setImportError] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head label={t('journey.studio.pages')} count={spreads.length} />
|
||||
<div className="st-panel-scroll">
|
||||
<div className="st-thumbs" ref={list.ref}>
|
||||
{spreads.map((sp, i) => {
|
||||
const editable = canEditSpread(i)
|
||||
return (
|
||||
<div className="st-thumb-row" key={sp.id}>
|
||||
<button type="button"
|
||||
className={`st-thumb ${i === active ? 'is-active' : ''} ${sp.id === hinted ? 'is-hinted' : ''}`}
|
||||
onClick={() => setActive(i)}
|
||||
>
|
||||
<SpreadThumb
|
||||
spread={sp}
|
||||
page={page}
|
||||
width={THUMB_W}
|
||||
pxPerMm={pxPerMm}
|
||||
folios={foliosOf(spreads, i, page.pageNumbers.startAt)}
|
||||
fold={bookView}
|
||||
className="st-thumb-sheet"
|
||||
/>
|
||||
<span className="st-thumb-label">
|
||||
{sp.role === 'cover' ? t('journey.studio.cover')
|
||||
: sp.role === 'back' ? t('journey.studio.backCover')
|
||||
: folioLabel(foliosOf(spreads, i, page.pageNumbers.startAt))}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/*
|
||||
The actions sit on the thumbnail rather than in a menu bar of
|
||||
their own: what you are acting on is the page you are pointing
|
||||
at, and a toolbar somewhere else would need you to select
|
||||
first and act second. The cover and the back cover have no
|
||||
actions — a book has exactly one of each, and moving or
|
||||
deleting them is not a thing anyone means to do.
|
||||
*/}
|
||||
{editable && (
|
||||
<div className="st-thumb-actions">
|
||||
<button type="button"
|
||||
onClick={() => moveSpread(i, -1)}
|
||||
disabled={!canEditSpread(i - 1)}
|
||||
title={t('journey.studio.movePageUp')}
|
||||
aria-label={t('journey.studio.movePageUp')}
|
||||
>
|
||||
<ChevronUp size={13} />
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => moveSpread(i, 1)}
|
||||
disabled={!canEditSpread(i + 1)}
|
||||
title={t('journey.studio.movePageDown')}
|
||||
aria-label={t('journey.studio.movePageDown')}
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => duplicateSpread(i)}
|
||||
title={t('journey.studio.duplicatePage')}
|
||||
aria-label={t('journey.studio.duplicatePage')}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className="is-danger"
|
||||
onClick={() => removeSpread(i)}
|
||||
title={t('journey.studio.deletePage')}
|
||||
aria-label={t('journey.studio.deletePage')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Insert between two spreads, where the new one will go — a
|
||||
single "add" at the end cannot express "another page here". */}
|
||||
{editable && (
|
||||
<button type="button"
|
||||
className="st-thumb-insert"
|
||||
onClick={() => addSpread(i)}
|
||||
title={t('journey.studio.addPageAfter')}
|
||||
aria-label={t('journey.studio.addPageAfter')}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Two ways to get a new page: an empty one, or somebody else's design.
|
||||
Side by side because they answer the same question — the import is
|
||||
not a setting buried in a menu, it is the other half of "add".
|
||||
*/}
|
||||
<div className="st-add-row">
|
||||
<button type="button" className="st-add-page" onClick={() => addSpread(lastInner)}
|
||||
title={t('journey.studio.addPage')} aria-label={t('journey.studio.addPage')}>
|
||||
<Plus size={14} />
|
||||
{t('journey.studio.addPageShort')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-add-page is-import"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
title={t('journey.studio.importSpreadHint')}
|
||||
>
|
||||
<FileDown size={14} />
|
||||
{t('journey.studio.importSpread')}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files?.[0]
|
||||
// Cleared straight away so choosing the same file twice in a row
|
||||
// still fires a change event the second time.
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setImportError(null)
|
||||
// Checked before reading rather than after: the point of a limit on
|
||||
// a file someone else wrote is not to parse it in the first place.
|
||||
if (file.size > MAX_SPREAD_FILE_BYTES) { setImportError(t('journey.studio.importSpreadFailed')); return }
|
||||
try {
|
||||
const spread = importSpread(JSON.parse(await file.text()), page)
|
||||
if (!spread) { setImportError(t('journey.studio.importSpreadFailed')); return }
|
||||
insertSpread(lastInner, spread)
|
||||
} catch {
|
||||
setImportError(t('journey.studio.importSpreadFailed'))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{importError && <p className="st-add-error">{importError}</p>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The journey's own material.
|
||||
*
|
||||
* This is what separates a book maker from a drawing program: the pictures and
|
||||
* the words are already written, and the job is putting them on pages. Clicking
|
||||
* an item drops it on the current spread, centred, at a sensible size.
|
||||
*
|
||||
* Pictures can also come in here. A book is where you notice the one that
|
||||
* never made it into the journal, and going back to the journey to add it, then
|
||||
* finding the page again, was the one round trip Studio still asked for. What
|
||||
* comes in goes through the journey store like any other upload, so the journal
|
||||
* sees it the moment the panel does.
|
||||
*/
|
||||
function ContentPanel({
|
||||
source, page, t, locale, canEdit, onUpload, providers = [], onBrowseProvider,
|
||||
}: {
|
||||
source: JourneySource
|
||||
page: BookPageSetup
|
||||
t: StudioT
|
||||
locale: string
|
||||
canEdit: boolean
|
||||
onUpload: StudioUploader
|
||||
providers?: { id: string; name: string }[]
|
||||
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>
|
||||
}) {
|
||||
const [tab, setTab] = useState<'photos' | 'text'>('photos')
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<PhotoFilter>({ kind: 'all' })
|
||||
const toast = useToast()
|
||||
|
||||
const entries = matchingEntries(source, query)
|
||||
const admitted = photosFor(source, filter)
|
||||
const photos = matchingPhotos(admitted, source, query)
|
||||
const emptyKey = emptyKeyFor(source, filter, admitted)
|
||||
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [upload, setUpload] = useState<{ done: number; total: number } | null>(null)
|
||||
/* A ref as well as the state: two drops in one second would both read the
|
||||
state before either had set it. */
|
||||
const sending = useRef(false)
|
||||
const [dropOver, setDropOver] = useState(false)
|
||||
|
||||
/**
|
||||
* Into the entry the browser is filtered to, otherwise into the gallery. The
|
||||
* filter is the one thing on screen that names an entry, so it is also the
|
||||
* one thing that can say where a picture belongs; the line under it says so
|
||||
* before the press. Afterwards the new pictures get a filter of their own,
|
||||
* because the gallery sorts them to its end, and twelve pictures that arrive
|
||||
* out of sight read as twelve that did not arrive.
|
||||
*/
|
||||
const send = async (files: File[]) => {
|
||||
if (sending.current || !files.length) return
|
||||
sending.current = true
|
||||
setUpload({ done: 0, total: files.length })
|
||||
try {
|
||||
const entryId = filter.kind === 'entry' ? filter.id : null
|
||||
const sent = await onUpload(files, entryId, p => setUpload({ done: p.done, total: p.total }))
|
||||
if (sent.photoIds.length) {
|
||||
setFilter({ kind: 'recent', photoIds: sent.photoIds })
|
||||
setQuery('')
|
||||
toast.success(t('journey.photosUploaded', { count: sent.photoIds.length }))
|
||||
}
|
||||
} finally {
|
||||
sending.current = false
|
||||
setUpload(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* From a photo provider (#2271): into the same place an upload goes, and the
|
||||
* new pictures shown the same way, under their own filter.
|
||||
*/
|
||||
const pickFromProvider = async (provider: string) => {
|
||||
if (!onBrowseProvider) return
|
||||
const photoIds = await onBrowseProvider(provider, filter.kind === 'entry' ? filter.id : null)
|
||||
if (photoIds.length) {
|
||||
setFilter({ kind: 'recent', photoIds })
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The whole scrolling area takes a drop, not just the dashed cell: a drag
|
||||
* from the desktop arrives with the pointer wherever it arrives, and a target
|
||||
* seventy pixels square is one you miss. Only a file drag is claimed; the
|
||||
* panel's own photo drags carry another type and go to the sheet.
|
||||
*/
|
||||
const dropZone = canEdit && tab === 'photos' ? {
|
||||
onDragOver: (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!e.dataTransfer.types.includes('Files')) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
setDropOver(true)
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => {
|
||||
// Moving onto a child of the box is not leaving the box.
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropOver(false)
|
||||
},
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>) => {
|
||||
const files = Array.from(e.dataTransfer.files ?? [])
|
||||
if (!files.length) return
|
||||
e.preventDefault()
|
||||
setDropOver(false)
|
||||
void send(files)
|
||||
},
|
||||
} : {}
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = spread && spread.role !== 'inner' ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
}
|
||||
|
||||
const dropPhoto = (photoId: number) => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.55
|
||||
addElement(active, {
|
||||
id: uid('p'), kind: 'photo', frame: centre(side, side * 0.75),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
/**
|
||||
* One piece of the entry, dropped on the page and still attached to it.
|
||||
*
|
||||
* `format` is what separates a place name from a set of coordinates: both
|
||||
* read the entry's location, and which of the two gets printed is a property
|
||||
* of the binding rather than a second source — see the contract. `width`
|
||||
* exists because a line of coordinates arriving inside a full-column
|
||||
* selection rectangle looks like a bug; only the short marks pass it.
|
||||
*/
|
||||
const dropText = (
|
||||
value: string,
|
||||
size: number,
|
||||
weight: 400 | 700,
|
||||
entryId: number,
|
||||
kind: 'entry.title' | 'entry.story' | 'entry.location' | 'entry.date',
|
||||
opts: { format?: CoordFormat; width?: number; value?: string } = {},
|
||||
) => {
|
||||
const w = opts.width ?? page.pageWidth - 32
|
||||
addElement(active, {
|
||||
id: uid('t'), kind: 'text', frame: centre(w, size * 0.4 + 10),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
text: value, font: 'sans', size, weight, italic: false,
|
||||
align: 'left', leading: weight === 700 ? 1.1 : 1.55, tracking: weight === 700 ? -0.02 : 0,
|
||||
color: '#1a1a1a',
|
||||
binding: {
|
||||
source: kind,
|
||||
entryId,
|
||||
...(opts.format ? { format: opts.format } : {}),
|
||||
// The fact behind the words, for the marks whose words are formatted —
|
||||
// see `value` in the contract.
|
||||
...(opts.value ? { value: opts.value } : {}),
|
||||
},
|
||||
overridden: false,
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
const base = {
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, stale: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* A mood or a weather mark, drawn with the icon the journey page uses.
|
||||
*
|
||||
* `code` holds the key rather than the label, so the element carries what the
|
||||
* entry recorded and the renderer decides how to draw it — which is what lets
|
||||
* the icon and the palette come from one place.
|
||||
*/
|
||||
const dropMark = (variant: 'mood' | 'weather', code: string, label: string) =>
|
||||
addElement(active, {
|
||||
...base, id: uid('bd'), kind: 'badge', autoColor: true,
|
||||
// Set here as well as in the contract: this object is cast, not parsed.
|
||||
showIcon: true, showLabel: true, autoIconColor: true, iconColor: '#111111',
|
||||
frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.062),
|
||||
variant, text: label, sub: '', code, style: 'plain',
|
||||
} as BookElement)
|
||||
|
||||
/** Both columns of an entry's pros and cons, as one element. */
|
||||
const dropProsCons = (pros: string[], cons: string[]) => {
|
||||
const rows = Math.max(pros.length, cons.length, 1)
|
||||
addElement(active, {
|
||||
...base, id: uid('li'), kind: 'list',
|
||||
// Matches what ListView needs for `rows` lines at its largest line size,
|
||||
// so the element arrives filled rather than with a third of it empty.
|
||||
frame: centre(page.pageWidth * 0.72, Math.min(page.pageHeight * 0.6, 6 * (0.86 + 1.7 * rows))),
|
||||
items: [
|
||||
...pros.map(text => ({ text, tone: 'pro' as const })),
|
||||
...cons.map(text => ({ text, tone: 'con' as const })),
|
||||
],
|
||||
layout: 'columns', showMarks: true,
|
||||
proLabel: t('journey.editor.pros'),
|
||||
conLabel: t('journey.editor.cons'),
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head label={t('journey.studio.content')} />
|
||||
|
||||
<div className="st-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('journey.studio.searchContent')}
|
||||
aria-label={t('journey.studio.searchContent')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="st-tabs">
|
||||
<button type="button" className={tab === 'photos' ? 'is-on' : ''} onClick={() => setTab('photos')}>
|
||||
{t('journey.studio.photos')} <em>{photos.length}</em>
|
||||
</button>
|
||||
<button type="button" className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
|
||||
{t('journey.studio.entries')} <em>{entries.length}</em>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Which entry, as a row of its own under the tabs rather than a control
|
||||
inside the search box: the search answers "which words" and this
|
||||
answers "which day", and the two combine. The chip opens the same
|
||||
popover the bar uses for formats, hung from its left edge because the
|
||||
panel has no room to the right of it.
|
||||
*/}
|
||||
{tab === 'photos' && (
|
||||
<StudioContentFilter
|
||||
source={source}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
admitted={admitted.length}
|
||||
pageEntryId={spread?.entryId ?? null}
|
||||
t={t}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`st-panel-scroll ${dropOver ? 'is-drop-over' : ''}`} {...dropZone}>
|
||||
{tab === 'photos' ? (
|
||||
<>
|
||||
{canEdit && (
|
||||
<p className="st-hint st-upload-where">
|
||||
{t(filter.kind === 'entry' ? 'journey.studio.uploadToEntry' : 'journey.studio.uploadToGallery')}
|
||||
</p>
|
||||
)}
|
||||
<div className="st-photo-grid">
|
||||
{/*
|
||||
The first cell rather than a button above the grid: it is the
|
||||
size and shape of what it produces, and it stays where the eye
|
||||
already is. Disabled, not hidden, while a batch is on its way,
|
||||
so the count has somewhere to be read.
|
||||
*/}
|
||||
{canEdit && (
|
||||
<button type="button"
|
||||
className="st-photo-cell is-upload"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={!!upload}
|
||||
title={t('journey.studio.uploadHint')}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.6} />
|
||||
<span>
|
||||
{upload
|
||||
? t('journey.studio.uploading', { done: upload.done, total: upload.total })
|
||||
: t('journey.studio.uploadPhotos')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{canEdit && onBrowseProvider && providers.map(p => (
|
||||
<button type="button"
|
||||
key={p.id}
|
||||
className="st-photo-cell is-upload"
|
||||
onClick={() => { void pickFromProvider(p.id) }}
|
||||
disabled={!!upload}
|
||||
title={t('journey.studio.fromProviderHint', { name: p.name })}
|
||||
>
|
||||
<ImagePlus size={18} strokeWidth={1.6} />
|
||||
<span>{t('journey.studio.fromProvider', { name: p.name })}</span>
|
||||
</button>
|
||||
))}
|
||||
{photos.map(p => (
|
||||
<button type="button"
|
||||
key={p.photoId}
|
||||
className="st-photo-cell"
|
||||
onClick={() => dropPhoto(p.photoId)}
|
||||
title={t('journey.studio.addToPage')}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
// HTML5 drag is the right tool for exactly this shape of
|
||||
// interaction (one item, from a list, onto a target) and it
|
||||
// gives us the thumbnail as the drag image for free. The
|
||||
// canvas uses pointer events instead, because free transform
|
||||
// needs a live position that a drop event cannot provide.
|
||||
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
>
|
||||
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
|
||||
</button>
|
||||
))}
|
||||
{!photos.length && <p className="st-hint">{t(emptyKey)}</p>}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.heic,.heif"
|
||||
hidden
|
||||
onChange={e => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
// Cleared straight away, as the spread import does, so the
|
||||
// same pictures can be chosen twice in a row.
|
||||
e.target.value = ''
|
||||
void send(files)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="st-entries">
|
||||
{entries.map(e => (
|
||||
<div key={e.id} className="st-entry">
|
||||
<div className="st-entry-head">{e.title || e.location || t('journey.studio.untitled')}</div>
|
||||
{(e.date || e.location) && (
|
||||
<div className="st-entry-meta">
|
||||
{/* Two facts, two badges. Joined by a dot they read as one
|
||||
string and the eye has to parse where the date ends. The
|
||||
date follows the app's language, like every other date in
|
||||
TREK — see utils/formatters.ts. */}
|
||||
{e.date && <span className="st-badge">{formatDate(e.date, locale) ?? e.date}</span>}
|
||||
{e.location && <span className="st-badge is-quiet">{e.location}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="st-row" style={{ marginTop: 7 }}>
|
||||
{e.title && (
|
||||
<button type="button" className="st-chip" onClick={() => dropText(e.title!, 22, 700, e.id, 'entry.title')}>
|
||||
{t('journey.studio.addTitle')}
|
||||
</button>
|
||||
)}
|
||||
{e.story && (
|
||||
<button type="button" className="st-chip" onClick={() => dropText(e.story!, 10, 400, e.id, 'entry.story')}>
|
||||
{t('journey.studio.addStory')}
|
||||
</button>
|
||||
)}
|
||||
{e.location && (
|
||||
<button type="button" className="st-chip" onClick={() => dropText(e.location!, 8, 700, e.id, 'entry.location')}>
|
||||
{t('journey.studio.addPlace')}
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
The day and the point. Both are set the way the rest of the
|
||||
book sets them — the long date, coordinates in degrees — so a
|
||||
mark placed here and one the auto layout placed read alike.
|
||||
*/}
|
||||
{e.date && (
|
||||
<button type="button"
|
||||
className="st-chip"
|
||||
onClick={() => dropText(
|
||||
formatBookDate(e.date!, locale), 8, 700, e.id, 'entry.date',
|
||||
{ width: page.pageWidth * 0.36, value: e.date! },
|
||||
)}
|
||||
>
|
||||
{t('journey.studio.dateMark')}
|
||||
</button>
|
||||
)}
|
||||
{e.lat != null && e.lng != null && (
|
||||
<button type="button"
|
||||
className="st-chip"
|
||||
onClick={() => dropText(
|
||||
formatBookCoords(e.lat!, e.lng!, 'dms'), 8, 700, e.id, 'entry.location',
|
||||
{ format: 'dms', width: page.pageWidth * 0.36, value: coordValue(e.lat!, e.lng!) },
|
||||
)}
|
||||
>
|
||||
{t('journey.studio.coordsMark')}
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
What the entry recorded besides its story. Each chip shows
|
||||
the value rather than the field name — "Sunny" tells you
|
||||
what you are about to place; "Weather" does not.
|
||||
*/}
|
||||
{e.mood && MOOD_CONFIG[e.mood] && (
|
||||
<button type="button"
|
||||
className="st-chip"
|
||||
onClick={() => dropMark('mood', e.mood!, t(MOOD_CONFIG[e.mood!].label))}
|
||||
>
|
||||
{t(MOOD_CONFIG[e.mood].label)}
|
||||
</button>
|
||||
)}
|
||||
{e.weather && WEATHER_CONFIG[e.weather] && (
|
||||
<button type="button"
|
||||
className="st-chip"
|
||||
onClick={() => dropMark('weather', e.weather!, t(WEATHER_CONFIG[e.weather!].label))}
|
||||
>
|
||||
{t(WEATHER_CONFIG[e.weather].label)}
|
||||
</button>
|
||||
)}
|
||||
{(e.pros.length > 0 || e.cons.length > 0) && (
|
||||
<button type="button" className="st-chip" onClick={() => dropProsCons(e.pros, e.cons)}>
|
||||
{t('journey.studio.addProsCons')}
|
||||
<em>{e.pros.length + e.cons.length}</em>
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
Not a drop like the others: this one takes you to the
|
||||
entry's pictures. The words of a day and its pictures were
|
||||
two tabs apart with nothing joining them, and the day is
|
||||
how anyone thinks of the pictures.
|
||||
*/}
|
||||
{e.photoIds.length > 0 && (
|
||||
<button type="button"
|
||||
className="st-chip"
|
||||
onClick={() => { setFilter({ kind: 'entry', id: e.id }); setTab('photos') }}
|
||||
>
|
||||
<Camera size={12} />
|
||||
{t('journey.studio.entryPhotos')}
|
||||
<em>{e.photoIds.length}</em>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!entries.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import { bookStickerSlotColor, type BookElement, type BookStickerElement } from '@trek/shared';
|
||||
import { bookSticker } from '@trek/shared/stickers';
|
||||
import { RotateCcw, RotateCw } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Line, NumField, Section, Switch } from './StudioControls';
|
||||
import { Line, Section } from './StudioControls';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
import { stickerLabel, turned, withSlotColor } from './stickerLibrary';
|
||||
import { stickerLabel, withSlotColor } from './stickerLibrary';
|
||||
|
||||
/**
|
||||
* The inspector's part for a sticker: its colours slot by slot, and how it
|
||||
* lies on the page (mirrored, turned, faded).
|
||||
* The inspector of a sticker: its colours slot by slot first, then where it
|
||||
* sits, which for a sticker is open from the start and holds its mirror and
|
||||
* its turn (StudioPositionSection.tsx).
|
||||
*
|
||||
* Every slot is offered with the colour it shows now, the sticker's own until
|
||||
* somebody picks another, and choosing one writes the colours of the slots
|
||||
* before it as well, so the list the document keeps always reads in slot order.
|
||||
* Turning is here as well as in the folded position numbers because a sticker
|
||||
* is the one element people set at an angle as a matter of course.
|
||||
*/
|
||||
|
||||
type Translate = (k: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** A quarter of a right angle and a little: what a sticker is tipped by, a step at a time. */
|
||||
const TURN_STEP = 15;
|
||||
|
||||
export function StudioStickerInspector({
|
||||
el,
|
||||
set,
|
||||
position,
|
||||
t,
|
||||
}: {
|
||||
el: BookStickerElement;
|
||||
set: (patch: Partial<BookElement>) => void;
|
||||
position: ReactNode;
|
||||
t: Translate;
|
||||
}) {
|
||||
const def = bookSticker(el.stickerId);
|
||||
@@ -47,7 +45,7 @@ export function StudioStickerInspector({
|
||||
</Line>
|
||||
))}
|
||||
{el.colors.length > 0 && (
|
||||
<div className="st-row" style={{ marginTop: 8 }}>
|
||||
<div className="st-row st-section-gap">
|
||||
<button type="button" className="st-chip" onClick={() => set({ colors: [] })}>
|
||||
{t('journey.studio.stickerResetColours')}
|
||||
</button>
|
||||
@@ -55,49 +53,7 @@ export function StudioStickerInspector({
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.stickerPlacement')}>
|
||||
<Switch label={t('journey.studio.stickerFlip')} on={el.flipX} onToggle={() => set({ flipX: !el.flipX })} />
|
||||
<div className="st-row" style={{ marginTop: 10, alignItems: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-chip is-icon"
|
||||
onClick={() => set({ rotation: turned(el.rotation, -TURN_STEP) })}
|
||||
title={t('journey.studio.stickerTurnLeft')}
|
||||
aria-label={t('journey.studio.stickerTurnLeft')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="st-chip is-icon"
|
||||
onClick={() => set({ rotation: turned(el.rotation, TURN_STEP) })}
|
||||
title={t('journey.studio.stickerTurnRight')}
|
||||
aria-label={t('journey.studio.stickerTurnRight')}
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.rotation')}
|
||||
value={el.rotation}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
unit="°"
|
||||
onChange={(v) => set({ rotation: v })}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.opacity')}
|
||||
value={Math.round(el.opacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
unit="%"
|
||||
onChange={(v) => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
{position}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BOOK_STICKER_THEMES, type BookPageSetup } from '@trek/shared';
|
||||
import { bookStickersOf, type BookStickerDef } from '@trek/shared/stickers';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { useMemo, useState, type DragEvent } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-stickers.css';
|
||||
import { StickerArt } from './StickerView';
|
||||
import { PanelHead } from './StudioPanelHead';
|
||||
import { Shelf } from './StudioPanelHead';
|
||||
import { StudioSearch } from './StudioSearch';
|
||||
import {
|
||||
STICKER_DRAG_TYPE,
|
||||
matchStickers,
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
} from './stickerLibrary';
|
||||
|
||||
/**
|
||||
* The sticker library, as a section of the rail: illustrations to put on a
|
||||
* page, grouped by theme, searchable in the reader's language, with the ones
|
||||
* this viewer placed last on a shelf of their own.
|
||||
* The sticker library, as the Stickers tab of the Elements panel:
|
||||
* illustrations to put on a page, grouped by theme, searchable in the
|
||||
* reader's language, with the ones this viewer placed last on a shelf of
|
||||
* their own.
|
||||
*
|
||||
* A click places a sticker in the middle of the page, a drag places it where
|
||||
* it is let go. Either way it arrives selected, so the inspector beside the
|
||||
@@ -87,44 +88,33 @@ export function StudioStickersPanel({ page, t }: { page: BookPageSetup; t: Trans
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.stickers')} count={browsing ? undefined : matches.length} />
|
||||
<div className="st-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('journey.studio.searchStickers')}
|
||||
aria-label={t('journey.studio.searchStickers')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<StudioSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
label={t('journey.studio.searchStickers')}
|
||||
clearLabel={t('common.clear')}
|
||||
count={matches.length}
|
||||
/>
|
||||
|
||||
<div className="st-panel-scroll">
|
||||
{browsing && recent.length > 0 && (
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.stickersRecent')}</div>
|
||||
<Shelf label={t('journey.studio.stickersRecent')}>
|
||||
<StickerGrid list={recent} t={t} onPick={place} />
|
||||
</div>
|
||||
</Shelf>
|
||||
)}
|
||||
|
||||
{/* Browsing, the library by theme; searching, one grid of what matches. */}
|
||||
{browsing ? (
|
||||
BOOK_STICKER_THEMES.map((id) => (
|
||||
<div className="st-section" key={id}>
|
||||
<div className="st-section-label">{stickerThemeLabel(t, id)}</div>
|
||||
<Shelf key={id} label={stickerThemeLabel(t, id)}>
|
||||
<StickerGrid list={bookStickersOf(id)} t={t} onPick={place} />
|
||||
</div>
|
||||
</Shelf>
|
||||
))
|
||||
) : (
|
||||
<div className="st-section">
|
||||
<Shelf>
|
||||
<StickerGrid list={matches} t={t} onPick={place} />
|
||||
{!matches.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
|
||||
</div>
|
||||
</Shelf>
|
||||
)}
|
||||
|
||||
<p className="st-hint">{t('journey.studio.stickersHint')}</p>
|
||||
|
||||
@@ -0,0 +1,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 });
|
||||
/** A number field, named by its label, followed by its value and unit. */
|
||||
const num = (label: string) => screen.getByRole('spinbutton', { name: (name) => name.startsWith(label) });
|
||||
/** The case and placement group, folded on a text that uses none of it. */
|
||||
const openPlacement = () => fireEvent.click(screen.getByRole('button', { name: 'journey.studio.fx.placement' }));
|
||||
|
||||
describe('case and placement', () => {
|
||||
it('folds away on a plain text and opens on one in small caps, set in the middle or fitted', () => {
|
||||
const plain = mount(text());
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.fx.placement' }).getAttribute('aria-expanded')).toBe(
|
||||
'false'
|
||||
);
|
||||
expect(screen.queryByRole('radio', { name: 'journey.studio.fx.caseSmall' })).toBeNull();
|
||||
plain.unmount();
|
||||
for (const over of [{ transform: 'smallcaps' }, { vAlign: 'middle' }, { fit: 'shrink' }]) {
|
||||
const used = mount(text(over));
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.fx.placement' }).getAttribute('aria-expanded')).toBe(
|
||||
'true'
|
||||
);
|
||||
used.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('sets the case and where the lines sit', () => {
|
||||
const { set } = mount(text());
|
||||
openPlacement();
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.caseSmall' }));
|
||||
expect(set).toHaveBeenCalledWith({ transform: 'smallcaps' });
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.middle' }));
|
||||
@@ -53,6 +72,7 @@ describe('case and placement', () => {
|
||||
|
||||
it('shrinks to fit, and gives the chosen size back when fitting is turned off', () => {
|
||||
const on = mount(text());
|
||||
openPlacement();
|
||||
fireEvent.click(toggle('journey.studio.fx.fit'));
|
||||
expect(on.set).toHaveBeenCalledWith({ fit: 'shrink' });
|
||||
on.unmount();
|
||||
|
||||
@@ -12,15 +12,17 @@ import { bookTextIsDrawn } from '@trek/shared';
|
||||
|
||||
import '../../styles/studio-text.css';
|
||||
import { photoSrc } from './bookRender';
|
||||
import { FocalPad } from './inspector/FocalPad';
|
||||
import { textEffectsInUse, textPlacementInUse } from './inspector/inspectorModel';
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls';
|
||||
import { FocalPad } from './StudioInspector';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
|
||||
|
||||
/**
|
||||
* The text inspector's effects: how the letters are cased and where the lines
|
||||
* sit, whether the text shrinks to its frame, and the outline, hard shadow,
|
||||
* marker band, curve and fill (book-text-effects.schema.ts in shared).
|
||||
* marker band, curve and fill (book-text-effects.schema.ts in shared). Both
|
||||
* groups start folded and open on a text that already uses them.
|
||||
*
|
||||
* Every effect is a switch that arrives already looking right: an outline in
|
||||
* the colour that stands out from the letters at a twentieth of their height,
|
||||
@@ -49,7 +51,7 @@ const FILL_PHOTOS = 12;
|
||||
|
||||
function CaseAndPlace({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
|
||||
return (
|
||||
<Section label={t('journey.studio.fx.placement')}>
|
||||
<Section label={t('journey.studio.fx.placement')} defaultOpen={textPlacementInUse(el)}>
|
||||
<Line label={t('journey.studio.fx.case')}>
|
||||
<Choice
|
||||
value={el.transform ?? 'none'}
|
||||
@@ -352,11 +354,10 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
|
||||
export function StudioTextEffects(props: Props) {
|
||||
const { el, set, t } = props;
|
||||
const any = !!(el.outline || el.shadow || el.highlight || el.curve || el.fillPaint);
|
||||
return (
|
||||
<>
|
||||
<CaseAndPlace el={el} set={set} t={t} />
|
||||
<Section label={t('journey.studio.fx.effects')} defaultOpen={any}>
|
||||
<Section label={t('journey.studio.fx.effects')} defaultOpen={textEffectsInUse(el)}>
|
||||
<OutlineControls el={el} set={set} t={t} />
|
||||
<ShadowControls el={el} set={set} t={t} />
|
||||
<BandControls el={el} set={set} t={t} />
|
||||
|
||||
@@ -75,9 +75,11 @@ describe('the Text styles shelf', () => {
|
||||
|
||||
it('opens the Elements panel on the type tab, the plain styles above the lockups', () => {
|
||||
render(<StudioElementsPanel page={page} t={t} source={source} />);
|
||||
expect(screen.getByRole('tab', { name: 'journey.studio.text' }).getAttribute('aria-selected')).toBe('true');
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.styleTitle' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.quoteMark' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.shapes' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.shapes' }));
|
||||
expect(screen.queryByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useElementSize } from '../../hooks/useElementSize';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-text.css';
|
||||
import { ElementView } from './SpreadView';
|
||||
import { Shelf } from './StudioPanelHead';
|
||||
import { firstFillPhoto } from './textEffects';
|
||||
import {
|
||||
SHELF_GROUPS,
|
||||
@@ -109,8 +110,7 @@ export function StudioTextStyles({
|
||||
return (
|
||||
<>
|
||||
{SHELF_GROUPS.map((group) => (
|
||||
<div className="st-section" key={group.id}>
|
||||
<div className="st-section-label">{t(`journey.studio.lockupGroup.${group.id}`)}</div>
|
||||
<Shelf key={group.id} label={t(`journey.studio.lockupGroup.${group.id}`)}>
|
||||
<div className="st-lockups">
|
||||
{shelfLockups(group.lockups).map((lockup) => (
|
||||
<LockupTile
|
||||
@@ -123,7 +123,7 @@ export function StudioTextStyles({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
<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 {
|
||||
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
||||
BookMetric, BookStatsElement, JourneyStats,
|
||||
@@ -14,14 +14,19 @@ import { Choice, Line, Picks, Section, Switch } from './StudioControls'
|
||||
import { fetchRoads } from './roadRoute'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { StudioThemeLink } from './theme/StudioThemeLink'
|
||||
import { mapZoomInUse, travelTypeInUse } from './inspector/inspectorModel'
|
||||
|
||||
/**
|
||||
* Properties of the travel elements.
|
||||
*
|
||||
* Its own file because these four are a different kind of thing from a photo or
|
||||
* Its own file because these five are a different kind of thing from a photo or
|
||||
* a paragraph: what you adjust is *what the element says*, not how a box looks.
|
||||
* Keeping them here leaves StudioInspector.tsx about the elements that have
|
||||
* been there since the beginning.
|
||||
* Keeping them here leaves the inspector about the elements that have been
|
||||
* there since the beginning.
|
||||
*
|
||||
* In the order every kind follows: what the element says first (a map's scope,
|
||||
* source and framing, a summary's figures, a mark's words), then where it sits,
|
||||
* then how it looks (a map's line and layers, the type, the colours).
|
||||
*/
|
||||
|
||||
export interface TravelInspectorProps {
|
||||
@@ -33,6 +38,8 @@ export interface TravelInspectorProps {
|
||||
set: (patch: Partial<BookElement>) => void
|
||||
t: (k: string) => string
|
||||
locale: string
|
||||
/** The position and size group, which every kind places after what it is. */
|
||||
position: ReactNode
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
@@ -41,7 +48,7 @@ interface Props<T> {
|
||||
t: (k: string) => string
|
||||
}
|
||||
|
||||
export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProps) {
|
||||
export function TravelInspector({ el, stats, path, set, t, position }: TravelInspectorProps) {
|
||||
const theme = useStudioStore(s => s.doc?.theme)
|
||||
if (
|
||||
el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries'
|
||||
@@ -70,12 +77,16 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{el.kind === 'map' && <MapProps el={el} set={set} t={t} stats={stats} journeyPath={path} />}
|
||||
{el.kind === 'map' && <MapContent el={el} set={set} t={t} stats={stats} journeyPath={path} />}
|
||||
{el.kind === 'stats' && <StatsProps el={el} set={set} t={t} />}
|
||||
{el.kind === 'countries' && <CountriesProps el={el} set={set} t={t} />}
|
||||
{el.kind === 'badge' && <BadgeProps el={el} set={set} t={t} />}
|
||||
{el.kind === 'list' && <ListProps el={el} set={set} t={t} />}
|
||||
|
||||
{position}
|
||||
|
||||
{el.kind === 'map' && <MapLook el={el} set={set} t={t} />}
|
||||
|
||||
{/*
|
||||
* Type, for all five alike.
|
||||
*
|
||||
@@ -83,11 +94,12 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
|
||||
* country's name over its outline — and until now the only thing you
|
||||
* could change about that was the size. A book set in a serif with its
|
||||
* stats block in the default sans is a book with one element in the
|
||||
* wrong typeface and no way to fix it.
|
||||
* wrong typeface and no way to fix it. Folded, and open on an element
|
||||
* already set in another face, weight or size.
|
||||
*/}
|
||||
<Section label={t('journey.studio.typography')} defaultOpen={false}>
|
||||
<Section label={t('journey.studio.typography')} defaultOpen={travelTypeInUse(el)}>
|
||||
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} t={t} />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="st-section-gap">
|
||||
<WeightLine
|
||||
font={el.font}
|
||||
weight={el.weight}
|
||||
@@ -147,7 +159,7 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
|
||||
onUnlink={() => set({ token: undefined } as Partial<BookElement>)} />
|
||||
</Line>
|
||||
{el.kind === 'badge' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="st-section-gap">
|
||||
<Switch
|
||||
label={t('journey.studio.autoColour')}
|
||||
on={el.autoColor}
|
||||
@@ -208,31 +220,9 @@ type MapPropsInput = Props<BookMapElement> & {
|
||||
journeyPath: [number, number][][]
|
||||
}
|
||||
|
||||
function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
/** What a map shows: which trip, where the picture comes from and its style, the framing and the zoom. */
|
||||
function MapContent({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
const cartoKey = useCartoApiKey()
|
||||
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
|
||||
const [roadDone, setRoadDone] = useState(0)
|
||||
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
|
||||
|
||||
/*
|
||||
* One leg at a time, paced, and written into the document when it is done
|
||||
* rather than leg by leg: a hundred commits would be a hundred undo steps and
|
||||
* a hundred autosaves for one action.
|
||||
*/
|
||||
const loadRoads = async () => {
|
||||
setRoadState('busy')
|
||||
setRoadDone(0)
|
||||
try {
|
||||
const roads = await fetchRoads(
|
||||
el.points.map(p => ({ lat: p.lat, lng: p.lng })),
|
||||
{ onProgress: done => setRoadDone(done) },
|
||||
)
|
||||
set({ roads } as Partial<BookElement>)
|
||||
} finally {
|
||||
setRoadState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
const sources = useMapSources(el.frame, el.points, { source: el.source, tileUrl: el.tileUrl })
|
||||
/** Only trips that put stops on the route: a map of the others is a blank. */
|
||||
const tripsWithRoute = (stats?.trips ?? []).filter(t => t.points > 0)
|
||||
@@ -328,7 +318,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
{el.source !== 'vector' && (
|
||||
<>
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>
|
||||
{t(SOURCE_HINT[sourceIdOf(el, sources)] ?? 'journey.studio.mapSourceHint')}
|
||||
{t(SOURCE_HINT[sourceIdOf(el, sources)]?.hintKey ?? 'journey.studio.mapSourceHint')}
|
||||
</p>
|
||||
{/*
|
||||
What this map will actually print at, at the size it is now.
|
||||
@@ -338,7 +328,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
{dpi != null && (
|
||||
<p className={`st-hint ${dpi < 150 ? 'st-stale' : ''}`} style={{ paddingTop: 6 }}>
|
||||
{t('journey.studio.mapPrintDpi')} {dpi} dpi
|
||||
{dpi < 150 && ` — ${t('journey.studio.mapPrintDpiLow')}`}
|
||||
{dpi < 150 && `, ${t('journey.studio.mapPrintDpiLow')}`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -408,7 +398,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
</Section>
|
||||
|
||||
{el.source === 'tiles' && (
|
||||
<Section label={t('journey.studio.mapZoom')} defaultOpen={false}>
|
||||
<Section label={t('journey.studio.mapZoom')} defaultOpen={mapZoomInUse(el)}>
|
||||
<Choice
|
||||
value={el.zoom ?? -1}
|
||||
options={[
|
||||
@@ -419,7 +409,40 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How a map looks: the line of the route, whether it follows the roads, and
|
||||
* which layers are drawn. After the position, like the look of every kind.
|
||||
*/
|
||||
function MapLook({ el, set, t }: Props<BookMapElement>) {
|
||||
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
|
||||
const [roadDone, setRoadDone] = useState(0)
|
||||
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
|
||||
|
||||
/*
|
||||
* One leg at a time, paced, and written into the document when it is done
|
||||
* rather than leg by leg: a hundred commits would be a hundred undo steps and
|
||||
* a hundred autosaves for one action.
|
||||
*/
|
||||
const loadRoads = async () => {
|
||||
setRoadState('busy')
|
||||
setRoadDone(0)
|
||||
try {
|
||||
const roads = await fetchRoads(
|
||||
el.points.map(p => ({ lat: p.lat, lng: p.lng })),
|
||||
{ onProgress: done => setRoadDone(done) },
|
||||
)
|
||||
set({ roads } as Partial<BookElement>)
|
||||
} finally {
|
||||
setRoadState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
How the line itself is drawn, and whether it follows real roads.
|
||||
|
||||
@@ -675,9 +698,9 @@ function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
|
||||
}
|
||||
|
||||
/** What each imagery source is for, said once where it is chosen. */
|
||||
const SOURCE_HINT: Record<string, string> = {
|
||||
relief: 'journey.studio.mapSourceReliefHint',
|
||||
satellite: 'journey.studio.mapSourceSatelliteHint',
|
||||
const SOURCE_HINT: Record<string, { hintKey: string }> = {
|
||||
relief: { hintKey: 'journey.studio.mapSourceReliefHint' },
|
||||
satellite: { hintKey: 'journey.studio.mapSourceSatelliteHint' },
|
||||
}
|
||||
|
||||
/** The variants that draw something as well as saying something. */
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats,
|
||||
} from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { PanelHead } from './StudioPanelHead'
|
||||
import { PanelHead, Shelf } from './StudioPanelHead'
|
||||
import { studioTravelContext } from './studioTravelContext'
|
||||
import { Section } from './StudioControls'
|
||||
import { TravelPreview } from './TravelPreview'
|
||||
@@ -85,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.
|
||||
*
|
||||
* The label sits in a grid cell about eighty pixels wide, where "Satellite ·
|
||||
* recommended" is two lines of ellipsis. A ring says the same thing in no
|
||||
* space at all, and the tooltip carries the word for anyone who wonders why
|
||||
* this card looks different.
|
||||
* The label sits in a grid cell about eighty pixels wide, where "Satellite,
|
||||
* recommended" would take both of its lines. A ring says the same thing in
|
||||
* no space at all, and the tooltip carries the word for anyone who wonders
|
||||
* why this card looks different.
|
||||
*/
|
||||
recommended?: boolean
|
||||
title?: string
|
||||
@@ -98,7 +98,7 @@ function Card({ label, onClick, children, recommended = false, title }: {
|
||||
<button type="button"
|
||||
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
|
||||
onClick={onClick}
|
||||
title={title ? `${label} — ${title}` : label}
|
||||
title={title ? `${label}: ${title}` : label}
|
||||
>
|
||||
{children}
|
||||
<span className="st-travel-card-label">{label}</span>
|
||||
@@ -343,8 +343,7 @@ export function StudioTravelPanel({
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.travel')} />
|
||||
<div className="st-panel-scroll">
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.singleFigures')}</div>
|
||||
<Shelf label={t('journey.studio.singleFigures')}>
|
||||
<div className="st-travel-grid">
|
||||
{BOOK_METRICS.map(metric => {
|
||||
const el = singleEl(metric)
|
||||
@@ -355,7 +354,7 @@ export function StudioTravelPanel({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
{/*
|
||||
The switch is here, beside the maps and the figures it changes, and
|
||||
@@ -380,7 +379,7 @@ export function StudioTravelPanel({
|
||||
defaultOpen={false}
|
||||
badge={`${stops.length - excludedCount}/${stops.length}`}
|
||||
>
|
||||
<ul className="st-stops">
|
||||
<ul className="st-route-stops">
|
||||
{stops.map((stop, i) => {
|
||||
const entryId = stop.entryId
|
||||
const on = !stop.excluded
|
||||
@@ -397,14 +396,14 @@ export function StudioTravelPanel({
|
||||
*/
|
||||
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
|
||||
return (
|
||||
<li key={entryId ?? `place-${i}`} className={`st-stop ${on ? '' : 'is-off'}`}>
|
||||
<li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
|
||||
{/*
|
||||
One line: the day, then the name, then the switch. The
|
||||
name and the day were stacked, which doubled the height
|
||||
of a list whose whole job is being read down.
|
||||
*/}
|
||||
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
|
||||
<span className="st-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
||||
<span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
||||
{/*
|
||||
The country only where there is more than one to tell
|
||||
apart. On a journey inside one country it is the same
|
||||
@@ -434,8 +433,7 @@ export function StudioTravelPanel({
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.routeMap')}</div>
|
||||
<Shelf label={t('journey.studio.routeMap')}>
|
||||
{stats.points.length ? (
|
||||
<>
|
||||
<div className="st-travel-grid">
|
||||
@@ -497,8 +495,8 @@ export function StudioTravelPanel({
|
||||
trip nobody wrote about would place an empty frame.
|
||||
*/}
|
||||
{tripsWithRoute.length > 1 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="st-section-label">{t('journey.studio.mapPerTrip')}</div>
|
||||
<div className="st-shelf-sub">
|
||||
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
|
||||
<div className="st-travel-grid">
|
||||
{tripsWithRoute.map(trip => {
|
||||
const el = mapEl('minimal', { tripId: trip.id })
|
||||
@@ -519,10 +517,9 @@ export function StudioTravelPanel({
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noRoute')}</p>
|
||||
)}
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.countries')}</div>
|
||||
<Shelf label={t('journey.studio.countries')}>
|
||||
{stats.countries.length ? (
|
||||
<>
|
||||
{/*
|
||||
@@ -565,10 +562,9 @@ export function StudioTravelPanel({
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noCountries')}</p>
|
||||
)}
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.marks')}</div>
|
||||
<Shelf label={t('journey.studio.marks')}>
|
||||
<div className="st-travel-grid">
|
||||
{marks.map(({ el, label }) => (
|
||||
<Card key={el.id} label={label} onClick={() => place(el)}>
|
||||
@@ -576,7 +572,7 @@ export function StudioTravelPanel({
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Shelf>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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,199 @@
|
||||
import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread } 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,
|
||||
};
|
||||
const photo = { ...el, frame, rotation: 0, fit: 'cover' } as BookElement;
|
||||
return { ...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'
|
||||
|
||||
function preset(id: PagePresetId, w: number, h: number, labelKey: string): PagePreset {
|
||||
/**
|
||||
* A format from its page size. Its name is given as `labelKey` where the format
|
||||
* is listed, where the check of the client's translation keys can read it.
|
||||
*/
|
||||
function preset(id: PagePresetId, w: number, h: number, { labelKey }: { labelKey: string }): PagePreset {
|
||||
return {
|
||||
id,
|
||||
pageWidthMm: w,
|
||||
@@ -56,14 +60,14 @@ function preset(id: PagePresetId, w: number, h: number, labelKey: string): PageP
|
||||
}
|
||||
|
||||
export const PAGE_PRESETS: Record<PagePresetId, PagePreset> = {
|
||||
'square-210': preset('square-210', 210, 210, 'journey.studio.formatSquare21'),
|
||||
'square-300': preset('square-300', 300, 300, 'journey.studio.formatSquare30'),
|
||||
'a4-landscape': preset('a4-landscape', 297, 210, 'journey.studio.formatA4Landscape'),
|
||||
'a4-portrait': preset('a4-portrait', 210, 297, 'journey.studio.formatA4Portrait'),
|
||||
'a5-landscape': preset('a5-landscape', 210, 148, 'journey.studio.formatA5Landscape'),
|
||||
'square-210': preset('square-210', 210, 210, { labelKey: 'journey.studio.formatSquare21' }),
|
||||
'square-300': preset('square-300', 300, 300, { labelKey: 'journey.studio.formatSquare30' }),
|
||||
'a4-landscape': preset('a4-landscape', 297, 210, { labelKey: 'journey.studio.formatA4Landscape' }),
|
||||
'a4-portrait': preset('a4-portrait', 210, 297, { labelKey: 'journey.studio.formatA4Portrait' }),
|
||||
'a5-landscape': preset('a5-landscape', 210, 148, { labelKey: 'journey.studio.formatA5Landscape' }),
|
||||
// The dimensions here are only the starting point the fields open on; the
|
||||
// document keeps whatever the user types.
|
||||
custom: preset('custom', 210, 210, 'journey.studio.formatCustom'),
|
||||
custom: preset('custom', 210, 210, { labelKey: 'journey.studio.formatCustom' }),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { FlipHorizontal2, Printer, RotateCcw, TriangleAlert } from 'lucide-react';
|
||||
|
||||
import '../../../styles/studio-photo.css';
|
||||
import { photoEdgesInUse } from '../inspector/inspectorModel';
|
||||
import { Line, NumField, Section, Switch } from '../StudioControls';
|
||||
import { Swatches } from '../StudioSwatches';
|
||||
import { StudioSlider } from './StudioSlider';
|
||||
@@ -264,7 +265,7 @@ export function PhotoLookSections(props: RowProps) {
|
||||
const raster = t('journey.studio.photo.rasterHint');
|
||||
return (
|
||||
<>
|
||||
<Section label={t('journey.studio.photo.edges')} defaultOpen={!!(look.border || look.shadow || look.cutout)}>
|
||||
<Section label={t('journey.studio.photo.edges')} defaultOpen={photoEdgesInUse(el)}>
|
||||
<EdgeRow
|
||||
label={t('journey.studio.photo.border')}
|
||||
value={look.border}
|
||||
|
||||
@@ -68,6 +68,14 @@ export function matchingEntries(source: JourneySource, query: string): JourneySo
|
||||
.some(v => v && v.toLowerCase().includes(q)))
|
||||
}
|
||||
|
||||
/** The four answers, each named as `emptyKey` so the check of the client's translation keys can read it. */
|
||||
const EMPTY = {
|
||||
none: { emptyKey: 'journey.studio.noPhotos' },
|
||||
unmatched: { emptyKey: 'journey.studio.noMatches' },
|
||||
entry: { emptyKey: 'journey.studio.noEntryPhotos' },
|
||||
loose: { emptyKey: 'journey.studio.noLoosePhotos' },
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Why the grid is empty, which is four different answers.
|
||||
*
|
||||
@@ -75,9 +83,9 @@ export function matchingEntries(source: JourneySource, query: string): JourneySo
|
||||
* on a day that has none, reads as the pictures having gone.
|
||||
*/
|
||||
export function emptyKeyFor(source: JourneySource, filter: PhotoFilter, admitted: Photo[]): string {
|
||||
if (!source.photos.length) return 'journey.studio.noPhotos'
|
||||
if (admitted.length) return 'journey.studio.noMatches'
|
||||
if (filter.kind === 'entry') return 'journey.studio.noEntryPhotos'
|
||||
if (filter.kind === 'loose') return 'journey.studio.noLoosePhotos'
|
||||
return 'journey.studio.noMatches'
|
||||
if (!source.photos.length) return EMPTY.none.emptyKey
|
||||
if (admitted.length) return EMPTY.unmatched.emptyKey
|
||||
if (filter.kind === 'entry') return EMPTY.entry.emptyKey
|
||||
if (filter.kind === 'loose') return EMPTY.loose.emptyKey
|
||||
return EMPTY.unmatched.emptyKey
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -14,11 +14,11 @@ import { Check, ImageIcon, RotateCcw } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import '../../../styles/studio-theme.css';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { Choice, Line, Picks, Section, Switch } from '../StudioControls';
|
||||
import { StudioFontPicker } from '../StudioFontPicker';
|
||||
import { PanelHead } from '../StudioPanelHead';
|
||||
import { PanelHead, Shelf } from '../StudioPanelHead';
|
||||
import type { StudioT } from '../StudioSidebar';
|
||||
import { ThemeSheet } from './ThemeSheet';
|
||||
import { useStudioTheme } from './useStudioTheme';
|
||||
|
||||
/**
|
||||
@@ -46,7 +46,6 @@ function ThemeCard({
|
||||
t: StudioT;
|
||||
}) {
|
||||
const display = useMemo(() => bookThemeOf(preset.id).fonts.display, [preset.id]);
|
||||
const { palette } = preset;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -55,23 +54,7 @@ function ThemeCard({
|
||||
onClick={onPick}
|
||||
title={t(preset.descrKey)}
|
||||
>
|
||||
<span className="st-theme-sheet" style={{ background: palette.paper }}>
|
||||
<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>
|
||||
<ThemeSheet palette={preset.palette} display={display} />
|
||||
<span className="st-theme-name">
|
||||
<span>{t(preset.labelKey)}</span>
|
||||
{inUse && (
|
||||
@@ -165,26 +148,27 @@ export function StudioThemePanel({ t }: { t: StudioT }) {
|
||||
<div className="st-panel-scroll st-theme">
|
||||
<p className="st-hint">{t('studio.theme.intro')}</p>
|
||||
|
||||
<div className="st-section-label">{t('studio.theme.themes')}</div>
|
||||
<div className="st-theme-grid">
|
||||
{BOOK_THEME_PRESETS.map((preset) => (
|
||||
<ThemeCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
on={draft.id === preset.id}
|
||||
inUse={theme.current?.id === preset.id}
|
||||
onPick={() => theme.pick(preset.id)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Shelf label={t('studio.theme.themes')}>
|
||||
<div className="st-theme-grid">
|
||||
{BOOK_THEME_PRESETS.map((preset) => (
|
||||
<ThemeCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
on={draft.id === preset.id}
|
||||
inUse={theme.current?.id === preset.id}
|
||||
onPick={() => theme.pick(preset.id)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{theme.edited && (
|
||||
<button type="button" className="st-theme-reset" onClick={theme.reset}>
|
||||
<RotateCcw size={13} />
|
||||
{t('studio.theme.reset')}
|
||||
</button>
|
||||
)}
|
||||
{theme.edited && (
|
||||
<button type="button" className="st-theme-reset" onClick={theme.reset}>
|
||||
<RotateCcw size={13} />
|
||||
{t('studio.theme.reset')}
|
||||
</button>
|
||||
)}
|
||||
</Shelf>
|
||||
|
||||
<Section label={t('studio.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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/* The tabs of the elements panel: each as wide as its word, so a longer word
|
||||
is never cut, and a strip that does not fit breaks onto a second row. */
|
||||
.trek-dash .st-tabs.is-fluid {
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.trek-dash .st-tabs.is-fluid button {
|
||||
flex: 1 1 auto;
|
||||
min-width: auto;
|
||||
padding: 0 10px;
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -53,25 +53,6 @@
|
||||
|
||||
/* ----------------- inspector: align and arrange ----------------- */
|
||||
|
||||
.trek-dash .st-row-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
align-self: center;
|
||||
margin: 0 3px;
|
||||
background: var(--line-2);
|
||||
}
|
||||
.trek-dash .st-arrange-to {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.trek-dash .st-arrange-to .st-line-label {
|
||||
width: auto;
|
||||
}
|
||||
.trek-dash .st-arrange-acts {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.trek-dash .st-section-body > .st-arrange-hint {
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
|
||||
@@ -225,11 +225,13 @@
|
||||
transparent 1.5px 4px
|
||||
);
|
||||
}
|
||||
/* Four to a row, so a long name ("Farbverlauf", "Dégradé") breaks inside the tab rather than being cut. */
|
||||
.trek-dash .st-paint-tab-label {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/* ============================================================================
|
||||
TREK Studio: the two side panels, the patterns they share.
|
||||
|
||||
The rail's groups, the tab rows inside the panels, the shelves of the panels
|
||||
on the left, and the parts of the inspector that every kind of element has:
|
||||
the position group, the arrange rows, the page and theme when nothing is
|
||||
selected. The rules for the classes studio.css already had (the head, a
|
||||
labelled line, a number field, a chip) stay in studio.css, edited where they
|
||||
were; this file holds what is new, which is why it can sit beside studio.css
|
||||
in either order.
|
||||
|
||||
Scoped under `.trek-dash` like every Studio rule, in the dashboard tokens
|
||||
only, with logical sides throughout. Text never sits in a box of a fixed
|
||||
width: it wraps, and the few labels that must stay on one line say so where
|
||||
they are set and carry the whole text in a title.
|
||||
============================================================================ */
|
||||
|
||||
/* ----------------- the rail: groups ----------------- */
|
||||
|
||||
.trek-dash .st-rail-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Between the sections that put things on a page and the two that are about the
|
||||
book and the page as a whole. A rule rather than a gap, so the two groups
|
||||
read as two even when the rail is long enough to scroll. */
|
||||
.trek-dash .st-rail-sep {
|
||||
flex: none;
|
||||
height: 1px;
|
||||
margin-block: 5px;
|
||||
margin-inline: 10px;
|
||||
background: var(--line-2);
|
||||
}
|
||||
|
||||
/* ----------------- tab rows inside a panel ----------------- */
|
||||
|
||||
/* A grid, so every tab keeps its word and the row ends flush with the panel. */
|
||||
.trek-dash .st-subtabs {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 0 12px 10px;
|
||||
flex: none;
|
||||
}
|
||||
.trek-dash .st-subtab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 5px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: calc(11.5px * var(--fs-scale-caption, 1));
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
color: var(--ink-3);
|
||||
background: color-mix(in oklch, var(--ink) 4%, transparent);
|
||||
transition:
|
||||
background 0.16s,
|
||||
color 0.16s;
|
||||
}
|
||||
/* With an icon the word goes under it, so the word has the whole cell. */
|
||||
.trek-dash .st-subtabs.has-icons .st-subtab {
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding-block: 7px 6px;
|
||||
}
|
||||
.trek-dash .st-subtab:hover {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.trek-dash .st-subtab.is-on {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.trek-dash .st-subtab:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-subtab:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
.trek-dash .st-subtab-label {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
}
|
||||
.trek-dash .st-subtab-icon {
|
||||
flex: none;
|
||||
}
|
||||
/* How many, as figures in the tab's own colour: quieter than the word, never a pill inside it. */
|
||||
.trek-dash .st-subtab-count {
|
||||
flex: none;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
/* The tab's panel takes the rest of the panel's height: a drop anywhere below
|
||||
the tabs lands, and a tab with a search keeps it above its own scroller. */
|
||||
.trek-dash [role='tabpanel'].st-panel-scroll {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.trek-dash .st-tab-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* An entry's chips, under its name and its day. */
|
||||
.trek-dash .st-entry-chips {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
/* A count inside a chip, in the chip's own colour: a footnote to its words. */
|
||||
.trek-dash .st-chip-count {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* How many a search matches, quiet figures at the end of the field. */
|
||||
.trek-dash .st-search-count {
|
||||
flex: none;
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* ----------------- shelves ----------------- */
|
||||
|
||||
.trek-dash .st-shelf > .st-shelf-hint {
|
||||
padding-top: 8px;
|
||||
}
|
||||
/* A second group inside a shelf, such as the maps per trip under the route maps. */
|
||||
.trek-dash .st-shelf-sub {
|
||||
margin-top: 12px;
|
||||
}
|
||||
/* What a growing library watches for to draw its next rows. */
|
||||
.trek-dash .st-shelf-tail {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/* ----------------- inspector: inside a group ----------------- */
|
||||
|
||||
/* The heading a folding group's head sits in: there for the outline a screen
|
||||
reader walks, with no margin or type of its own on the page. */
|
||||
.trek-dash .st-section-title {
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* The room between two parts of one group, where the part below is not a labelled line. */
|
||||
.trek-dash .st-section-gap {
|
||||
margin-top: 10px;
|
||||
}
|
||||
/* A part that is there but not in force, such as the page number colour while it is automatic. */
|
||||
.trek-dash .st-section-gap.is-quiet {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.trek-dash .st-focal-wrap > .st-focal-hint,
|
||||
.trek-dash .st-section-body > .st-hint-after {
|
||||
padding: 6px 0 0;
|
||||
}
|
||||
.trek-dash .st-section-body > .st-hint-before {
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
/* A search inside a group, such as the icon swap, takes the group's own width. */
|
||||
.trek-dash .st-section-body > .st-search {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
/* ----------------- inspector: nothing selected ----------------- */
|
||||
|
||||
/* What the panel is for until something is picked, said once at its top. */
|
||||
.trek-dash .st-empty-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
margin: 2px 2px 4px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in oklch, var(--ink) 4%, transparent);
|
||||
color: var(--ink-2);
|
||||
font-size: calc(12px * var(--fs-scale-caption, 1));
|
||||
line-height: 1.5;
|
||||
}
|
||||
.trek-dash .st-empty-note svg {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* The book's theme, as the Theme panel draws it, and the way to it. */
|
||||
.trek-dash .st-theme-sum {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.trek-dash .st-theme-sum .st-theme-sheet {
|
||||
flex: none;
|
||||
width: 86px;
|
||||
height: 52px;
|
||||
}
|
||||
.trek-dash .st-theme-sum-name {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: calc(12.5px * var(--fs-scale-caption, 1));
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ----------------- inspector: position and size ----------------- */
|
||||
|
||||
.trek-dash .st-position-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.trek-dash .st-position-row.st-row {
|
||||
align-items: center;
|
||||
}
|
||||
.trek-dash .st-section-body > .st-position-row + .st-switch {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ----------------- inspector: arrange ----------------- */
|
||||
|
||||
.trek-dash .st-arrange .st-row {
|
||||
align-items: center;
|
||||
}
|
||||
/* Two sets of buttons on one row, a wider gap between them than inside them.
|
||||
A set that does not fit moves under the other whole, never one button of it.
|
||||
Named with `.st-row` so it outranks studio.css in either order. */
|
||||
.trek-dash .st-row.st-arrange-row {
|
||||
gap: 6px 12px;
|
||||
}
|
||||
.trek-dash .st-arrange-set {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* ----------------- inspector: a row of actions under a group ----------------- */
|
||||
|
||||
/* Fill the page, fill the spread: the commonest layout in a photo book, one press under the crop. */
|
||||
.trek-dash .st-photo-fill {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* ----------------- travel: the route as rows ----------------- */
|
||||
|
||||
/*
|
||||
* Not tiles: a stop is not something to place, it is the input every map and
|
||||
* figure below reads. One line per stop, the day first and the switch last, so
|
||||
* the eye runs down the end of the rows to find the one that is off. A
|
||||
* left-out row is dimmed as a whole rather than struck through: it is still a
|
||||
* day of the journal, just not a leg of the route.
|
||||
*
|
||||
* A row is a row rather than a card: fourteen bordered boxes down a narrow
|
||||
* panel is a wall, and what the list needs is to be read quickly, not to have
|
||||
* each of its entries framed. The separators do the same work for a fifth of
|
||||
* the height. The names are `st-route-stop` because `st-stop` is a gradient's
|
||||
* colour stop (studio-paint.css), whose rules laid every row of this list out
|
||||
* as a sixteen pixel handle while both were called the same.
|
||||
*/
|
||||
.trek-dash .st-route-stops {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.trek-dash .st-route-stop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition: opacity 0.16s;
|
||||
}
|
||||
.trek-dash .st-route-stop:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.trek-dash .st-route-stop.is-off {
|
||||
opacity: 0.55;
|
||||
}
|
||||
/* A long place name wraps under itself rather than pushing the switch off the row. */
|
||||
.trek-dash .st-route-stop-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: calc(12px * var(--fs-scale-caption, 1));
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* The day keeps its width, as on the entry cards; the country gives way, and a
|
||||
long one ("Bosnien und Herzegowina") wraps under itself rather than being cut. */
|
||||
.trek-dash .st-route-stop > .st-badge {
|
||||
flex: none;
|
||||
}
|
||||
.trek-dash .st-route-stop > .st-badge.is-quiet {
|
||||
flex: 0 1 auto;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.25;
|
||||
text-align: end;
|
||||
}
|
||||
/* The switch, as a mark: a square the size of the badges beside it. */
|
||||
.trek-dash .st-route-stop > .st-chip.is-icon {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.trek-dash .st-route-stop > .st-chip:not(.is-on) {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
/* The same chip as a plain label: a viewer's, or a stop with no entry to switch. */
|
||||
.trek-dash .st-route-stop > span.st-chip {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -6,29 +6,40 @@
|
||||
*/
|
||||
|
||||
/* ----------------- a slider with its value ----------------- */
|
||||
/* The name and the value on one line, the track under them across the whole
|
||||
width: a name has the line to itself ("Насыщенность", "Átlátszatlanság")
|
||||
instead of a column it would have to fit, and the tracks of a group line up. */
|
||||
.trek-dash .st-slider {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr) 40px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
'label value'
|
||||
'track track';
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
gap: 2px 8px;
|
||||
}
|
||||
.trek-dash .st-slider + .st-slider {
|
||||
margin-top: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.trek-dash .st-slider-label {
|
||||
font-size: 11px;
|
||||
grid-area: label;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
color: var(--ink-3);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.trek-dash .st-slider input[type='range'] {
|
||||
grid-area: track;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.trek-dash .st-slider-value {
|
||||
font-size: 11px;
|
||||
grid-area: value;
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
color: var(--ink-2);
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@@ -77,21 +77,23 @@
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--ink) 12%, transparent);
|
||||
}
|
||||
|
||||
/* A theme's name wraps under itself: "Scrapbook-Schrift" is a name, not something to cut. */
|
||||
.trek-dash .st-theme-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 0 3px 2px;
|
||||
font-size: 11.5px;
|
||||
font-size: calc(11.5px * var(--fs-scale-caption, 1));
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.trek-dash .st-theme-name > span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
}
|
||||
.trek-dash .st-theme-inuse {
|
||||
display: inline-flex;
|
||||
@@ -154,10 +156,11 @@
|
||||
}
|
||||
.trek-dash .st-theme-colour-name {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10.5px;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
@@ -168,10 +171,13 @@
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-size: calc(12px * var(--fs-scale-caption, 1));
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
transition:
|
||||
background 0.16s,
|
||||
color 0.16s,
|
||||
|
||||
+119
-167
@@ -310,44 +310,51 @@
|
||||
min-height: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.trek-dash .st-pages { border-inline-end: 1px solid var(--glass-border); }
|
||||
.trek-dash .st-inspector { border-inline-start: 1px solid var(--glass-border); }
|
||||
|
||||
.trek-dash .st-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--ink-3);
|
||||
/* One height with actions or without, so the heads of both sides line up. */
|
||||
min-height: 50px;
|
||||
padding-block: 10px 8px;
|
||||
padding-inline: 14px 12px;
|
||||
flex: none;
|
||||
}
|
||||
.trek-dash .st-panel-scroll { overflow-y: auto; overflow-x: hidden; padding: 0 12px 14px; min-height: 0; }
|
||||
|
||||
/* ----------------- inspector: the head ----------------- */
|
||||
/* ----------------- panel head: the title ----------------- */
|
||||
/*
|
||||
* The panel's own title, and the three things you do to what it is about.
|
||||
*
|
||||
* It is set larger and darker than a section label on purpose: those were the
|
||||
* Every panel's own title, on both sides, and the things you do to what it is
|
||||
* about. Set larger and darker than a section label on purpose: those were the
|
||||
* same small capitals in the same grey, so a panel with eight groups had nine
|
||||
* headings that all looked alike and no top.
|
||||
*/
|
||||
.trek-dash .st-inspector-head { padding: 12px 12px 10px; text-transform: none; letter-spacing: 0; }
|
||||
.trek-dash .st-inspector-head .st-what {
|
||||
.trek-dash .st-panel-head .st-what {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 4px 7px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-size: calc(13px * var(--fs-scale-subtitle, 1));
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-inspector-head .st-what em {
|
||||
.trek-dash .st-what-label { min-width: 0; overflow-wrap: anywhere; hyphens: auto; }
|
||||
.trek-dash .st-head-meta {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: end;
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.trek-dash .st-panel-head .st-what em {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -356,7 +363,7 @@
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--ink) 8%, transparent);
|
||||
font-size: 10.5px;
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -499,6 +506,8 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 6px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: var(--glass-bg);
|
||||
border-inline-end: 1px solid var(--glass-border);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
@@ -506,8 +515,10 @@
|
||||
z-index: 1;
|
||||
}
|
||||
.trek-dash .st-rail-btn {
|
||||
flex: none;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
padding: 6px 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -517,71 +528,28 @@
|
||||
color: var(--ink-3);
|
||||
transition: background .16s, color .16s;
|
||||
}
|
||||
/* Two lines at most, broken inside a word that is longer than the rail is wide
|
||||
("Путешествие", "Tərtibatlar"); the button's title carries the whole name. */
|
||||
.trek-dash .st-rail-btn span {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
font-size: calc(9.5px * var(--fs-scale-caption, 1));
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
}
|
||||
.trek-dash .st-rail-btn:focus { outline: none; }
|
||||
.trek-dash .st-rail-btn:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 3px var(--accent); }
|
||||
.trek-dash .st-rail-btn:hover { background: color-mix(in oklch, var(--ink) 6%, transparent); color: var(--ink-2); }
|
||||
.trek-dash .st-rail-btn.is-on { background: var(--accent-soft); color: var(--accent-ink); }
|
||||
|
||||
.trek-dash .st-side { border-inline-end: 1px solid var(--glass-border); }
|
||||
|
||||
.trek-dash .st-tabs {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 0 12px 10px;
|
||||
flex: none;
|
||||
}
|
||||
.trek-dash .st-tabs button {
|
||||
flex: 1;
|
||||
/* A flex item will not shrink below its own content width unless told to, so
|
||||
`flex: 1` alone does not give equal thirds — the tab with the longest word
|
||||
stays wide and squeezes the others. This is what makes them actually equal. */
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-3);
|
||||
background: color-mix(in oklch, var(--ink) 4%, transparent);
|
||||
transition: background .16s, color .16s;
|
||||
/* A label that still will not fit is cut rather than wrapped: a wrapped tab
|
||||
grows taller than the row and knocks the whole strip out of line. */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* The count is a footnote to the label, not part of it — a badge keeps it from
|
||||
competing with the word for the same weight. */
|
||||
.trek-dash .st-tabs button em {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 15px;
|
||||
margin-inline-start: 5px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--ink) 10%, transparent);
|
||||
color: var(--ink-3);
|
||||
font-style: normal;
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.trek-dash .st-tabs button.is-on em {
|
||||
background: color-mix(in oklch, var(--accent-text, #fff) 24%, transparent);
|
||||
color: var(--accent-text, #fff);
|
||||
}
|
||||
.trek-dash .st-tabs button:hover { color: var(--ink-2); }
|
||||
.trek-dash .st-tabs button.is-on { background: var(--accent); color: var(--accent-text, #fff); }
|
||||
|
||||
/* ----------------- search ----------------- */
|
||||
.trek-dash .st-search {
|
||||
display: flex;
|
||||
@@ -680,8 +648,9 @@
|
||||
.trek-dash .st-tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 44px;
|
||||
padding: 0 13px;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 6px 13px;
|
||||
border-radius: 10px;
|
||||
text-align: start;
|
||||
color: var(--ink);
|
||||
@@ -943,6 +912,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
padding: 13px 2px 11px;
|
||||
color: var(--ink-3);
|
||||
transition: color .14s;
|
||||
@@ -950,7 +920,10 @@
|
||||
.trek-dash .st-section-head:hover { color: var(--ink); }
|
||||
.trek-dash .st-section-head:hover .st-section-label { color: inherit; }
|
||||
.trek-dash .st-section-label {
|
||||
font-size: 10.5px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.11em;
|
||||
@@ -989,21 +962,29 @@
|
||||
|
||||
/* ----------------- inspector: one labelled line ----------------- */
|
||||
/*
|
||||
* The label carries the *role* — accent, text, icon — which is the thing three
|
||||
* The label carries the *role* (accent, text, icon), which is the thing three
|
||||
* identical swatch rows under three identical headings could never say.
|
||||
*
|
||||
* Label and control share the line while both fit and the control wraps under
|
||||
* the label when they do not, so no label is cut and no control is squeezed:
|
||||
* the label has no column of its own to overflow, only a common minimum that
|
||||
* starts the controls of short labels at one edge.
|
||||
*/
|
||||
.trek-dash .st-line { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.trek-dash .st-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; }
|
||||
.trek-dash .st-line + .st-line { margin-top: 8px; }
|
||||
.trek-dash .st-line.is-wide { display: block; }
|
||||
.trek-dash .st-line-label {
|
||||
flex: none;
|
||||
width: 68px; overflow-wrap: anywhere; hyphens: auto; /* "Ausrichtung" fits, a longer word breaks */
|
||||
font-size: 11px;
|
||||
flex: 0 0 auto;
|
||||
min-width: min(4.75em, 100%);
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
color: var(--ink-3);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.trek-dash .st-line.is-wide .st-line-label { display: block; margin-bottom: 7px; width: auto; }
|
||||
.trek-dash .st-line-body { flex: 1; min-width: 0; }
|
||||
.trek-dash .st-line.is-wide .st-line-label { display: block; margin-bottom: 7px; }
|
||||
.trek-dash .st-line-body { flex: 1 1 10rem; min-width: 0; }
|
||||
|
||||
/* ----------------- inspector: a switch on its own line ----------------- */
|
||||
.trek-dash .st-switch {
|
||||
@@ -1017,9 +998,19 @@
|
||||
.trek-dash .st-switch-label { font-size: 12px; color: var(--ink-2); min-width: 0; }
|
||||
|
||||
/* ----------------- inspector: a number with its unit ----------------- */
|
||||
.trek-dash .st-num { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.trek-dash .st-num-label { flex: none; font-size: 11px; color: var(--ink-3); min-width: 13px; }
|
||||
.trek-dash .st-num-box { position: relative; flex: 1; min-width: 0; display: flex; }
|
||||
/* The field keeps room for a number; a label too long to sit beside it goes above it. */
|
||||
.trek-dash .st-num { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; min-width: 0; }
|
||||
.trek-dash .st-num-label {
|
||||
flex: 0 1 auto;
|
||||
min-width: 13px;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
line-height: 1.25;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.trek-dash .st-num-box { position: relative; flex: 1 0 4.5rem; min-width: 4.5rem; display: flex; }
|
||||
.trek-dash .st-num-box .st-input { padding-inline-end: 26px; }
|
||||
/* The unit sits inside the field, so the number and what it is in read as one
|
||||
value rather than as a field with a word next to it. */
|
||||
@@ -1037,13 +1028,17 @@
|
||||
.trek-dash .st-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.trek-dash .st-row { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
|
||||
.trek-dash .st-field { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
||||
.trek-dash .st-field { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 7px; min-width: 0; }
|
||||
.trek-dash .st-field > span {
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
flex: none;
|
||||
flex: 0 1 auto;
|
||||
min-width: 13px;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
line-height: 1.25;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.trek-dash .st-field > .st-input { flex: 1 0 4.5rem; min-width: 4.5rem; }
|
||||
.trek-dash .st-field input[type="range"] { flex: 1; min-width: 0; accent-color: var(--accent); }
|
||||
|
||||
.trek-dash .st-input {
|
||||
@@ -1068,24 +1063,28 @@
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* A label wraps inside its chip rather than running past the panel's edge. */
|
||||
.trek-dash .st-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 27px;
|
||||
padding: 0 10px;
|
||||
min-height: 27px;
|
||||
max-width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--ink) 5%, transparent);
|
||||
color: var(--ink-2);
|
||||
font-size: 11.5px;
|
||||
font-size: calc(11.5px * var(--fs-scale-caption, 1));
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
transition: background .16s, color .16s;
|
||||
}
|
||||
.trek-dash .st-chip:hover { background: color-mix(in oklch, var(--ink) 10%, transparent); color: var(--ink); }
|
||||
.trek-dash .st-chip.is-on { background: var(--accent); color: var(--accent-text, #fff); }
|
||||
.trek-dash .st-chip.is-icon { width: 27px; padding: 0; }
|
||||
.trek-dash .st-chip.is-icon { width: 27px; height: 27px; padding: 0; flex: none; }
|
||||
/*
|
||||
* Choose-several, which is a different question from choose-one and has to
|
||||
* look like it: an outline that fills only with the tick, so a set of seven
|
||||
@@ -1392,17 +1391,19 @@
|
||||
background: #ffffff; /* theme-lint-disable — the preview stands on paper, not on the panel */
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--ink) 7%, transparent);
|
||||
}
|
||||
/* Two lines at most, the whole name in the card's title: a card keeps its height in the grid. */
|
||||
.trek-dash .st-travel-card-label {
|
||||
display: block;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
padding: 0 2px 1px;
|
||||
font-size: 10.5px;
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
color: var(--ink-3);
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.trek-dash .st-travel-card:hover .st-travel-card-label { color: var(--ink-2); }
|
||||
|
||||
@@ -1486,7 +1487,11 @@
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding-block: 6px;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed var(--line-2);
|
||||
@@ -1798,61 +1803,6 @@
|
||||
background: var(--line-2);
|
||||
}
|
||||
|
||||
/* stops */
|
||||
|
||||
/*
|
||||
* The route as rows, in the Travel panel.
|
||||
*
|
||||
* Not tiles: a stop is not something to place, it is the input every map and
|
||||
* figure below reads. One line per stop, the day first and the switch last, so
|
||||
* the eye runs down the right-hand edge to find the one that is off. A
|
||||
* left-out row is dimmed as a whole rather than struck through: it is still a
|
||||
* day of the journal, just not a leg of the route.
|
||||
*
|
||||
* A row is a row rather than a card: fourteen bordered boxes down a 236px
|
||||
* panel is a wall, and what the list needs is to be read quickly, not to have
|
||||
* each of its entries framed. The separators do the same work for a fifth of
|
||||
* the height.
|
||||
*/
|
||||
.trek-dash .st-stops {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.trek-dash .st-stop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition: opacity .16s;
|
||||
}
|
||||
.trek-dash .st-stop:last-child { border-bottom: 0; }
|
||||
.trek-dash .st-stop.is-off { opacity: .55; }
|
||||
.trek-dash .st-stop-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* The day keeps its width, as on the entry cards; the country gives way. */
|
||||
.trek-dash .st-stop > .st-badge { flex: none; }
|
||||
.trek-dash .st-stop > .st-badge.is-quiet { flex: 0 1 auto; }
|
||||
/* The switch, as a mark: a square the size of the badges beside it. The
|
||||
padding is reset because `is-pick` sets its own further down the file. */
|
||||
.trek-dash .st-stop > .st-chip.is-icon { flex: none; width: 22px; height: 22px; padding: 0; }
|
||||
.trek-dash .st-stop > .st-chip:not(.is-on) { color: var(--ink-3); }
|
||||
/* The same chip as a plain label: a viewer's, or a stop with no entry to switch. */
|
||||
.trek-dash .st-stop > span.st-chip { pointer-events: none; }
|
||||
|
||||
/* content filter + upload */
|
||||
/*
|
||||
* Which pictures the browser shows, and the way pictures get in (#2064).
|
||||
@@ -1899,12 +1849,16 @@
|
||||
.trek-dash .st-filter-chip > button + button { padding-block: 0; padding-inline: 3px 8px; margin-inline-start: -5px; }
|
||||
.trek-dash .st-filter-chip > button:hover { background: color-mix(in oklch, var(--ink) 7%, transparent); }
|
||||
.trek-dash .st-filter-chip.is-on > button:hover { background: color-mix(in oklch, var(--accent-text, #fff) 14%, transparent); }
|
||||
/* Two lines of an entry's name at most; the menu below it shows them whole. */
|
||||
.trek-dash .st-filter-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.25;
|
||||
text-align: start;
|
||||
}
|
||||
/* The count, set like the one in the tabs: a footnote, not part of the name. */
|
||||
@@ -1925,9 +1879,7 @@
|
||||
.trek-dash .st-filter-row .st-menu-item { padding: 7px 10px; gap: 8px; }
|
||||
.trek-dash .st-filter-row .st-menu-name,
|
||||
.trek-dash .st-filter-row .st-menu-dim {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.trek-dash .st-filter-count { flex: none; }
|
||||
|
||||
@@ -1957,9 +1909,9 @@
|
||||
transition: border-color .16s, color .16s, background .16s, transform .14s ease-out, box-shadow .14s;
|
||||
}
|
||||
.trek-dash .st-photo-cell.is-upload span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; max-width: 100%; overflow: hidden;
|
||||
/* Two lines that break inside a long word; the button's title holds the whole of it. */
|
||||
overflow-wrap: anywhere; hyphens: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.trek-dash .st-photo-cell.is-upload:hover:not(:disabled) {
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('the filter menu', () => {
|
||||
describe('the way in from an entry', () => {
|
||||
it('opens the pictures of the day from its card', () => {
|
||||
openContent()
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.entries/ }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: /journey\.studio\.entries/ }))
|
||||
|
||||
const card = screen.getByText('Reykjavik').closest('.st-entry') as HTMLElement
|
||||
fireEvent.click(within(card).getByText('journey.studio.entryPhotos'))
|
||||
@@ -171,7 +171,7 @@ describe('the way in from an entry', () => {
|
||||
|
||||
it('offers no such chip on a day without pictures', () => {
|
||||
openContent()
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.entries/ }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: /journey\.studio\.entries/ }))
|
||||
|
||||
const card = screen.getByText('A written day').closest('.st-entry') as HTMLElement
|
||||
expect(within(card).queryByText('journey.studio.entryPhotos')).toBeNull()
|
||||
@@ -195,6 +195,15 @@ describe('the way in from an entry', () => {
|
||||
})
|
||||
|
||||
describe('the upload cell', () => {
|
||||
/* A square a third of the panel wide holds its words to two lines; the tooltip has the rest. */
|
||||
it('names itself in full in its title, ahead of what it does', () => {
|
||||
openContent()
|
||||
|
||||
const cell = document.querySelector('.st-photo-cell.is-upload')
|
||||
expect(cell?.getAttribute('title')).toBe('journey.studio.uploadPhotos\njourney.studio.uploadHint')
|
||||
expect(cell?.querySelector('span')?.textContent).toBe('journey.studio.uploadPhotos')
|
||||
})
|
||||
|
||||
it('is not there for a viewer', () => {
|
||||
openContent(source, { canEdit: false })
|
||||
|
||||
@@ -273,7 +282,7 @@ describe('photos from a provider (#2271)', () => {
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByLabelText('journey.studio.content'))
|
||||
fireEvent.click(screen.getByTitle('journey.studio.fromProviderHint'))
|
||||
fireEvent.click(screen.getByTitle(/journey\.studio\.fromProviderHint/))
|
||||
expect(onBrowseProvider).toHaveBeenCalledWith('immich', null)
|
||||
await waitFor(() => expect(shown()).toEqual([14]))
|
||||
})
|
||||
@@ -289,13 +298,13 @@ describe('photos from a provider (#2271)', () => {
|
||||
)
|
||||
fireEvent.click(screen.getByLabelText('journey.studio.content'))
|
||||
const before = shown()
|
||||
fireEvent.click(screen.getByTitle('journey.studio.fromProviderHint'))
|
||||
fireEvent.click(screen.getByTitle(/journey\.studio\.fromProviderHint/))
|
||||
await waitFor(() => expect(onBrowseProvider).toHaveBeenCalled())
|
||||
expect(shown()).toEqual(before)
|
||||
unmount()
|
||||
|
||||
openContent(source, { canEdit: false })
|
||||
expect(screen.queryByTitle('journey.studio.fromProviderHint')).toBeNull()
|
||||
expect(screen.queryByTitle(/journey\.studio\.fromProviderHint/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ function openEntries(source: JourneySource) {
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByLabelText('journey.studio.content'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.entries/ }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: /journey\.studio\.entries/ }))
|
||||
return view
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,29 @@ function pickSwatch(c: HTMLElement, lineLabel: string, hex: string) {
|
||||
fireEvent.click(line(c, lineLabel).querySelector(`[aria-label="${hex}"]`)!)
|
||||
}
|
||||
|
||||
/**
|
||||
* A group by its heading. A group's first control answers the group's own
|
||||
* question and carries no label of its own (the text's colour under Colour, a
|
||||
* shape's paint under Fill), so it is found by the group it sits in.
|
||||
*/
|
||||
function section(c: HTMLElement, label: string): HTMLElement {
|
||||
const found = [...c.querySelectorAll<HTMLElement>('.st-section')]
|
||||
.find(s => s.querySelector('.st-section-label')?.textContent === label)
|
||||
if (!found) throw new Error(`no section headed ${label}`)
|
||||
return found
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** A swatch in the first swatch row of a group. */
|
||||
function pickSwatchIn(c: HTMLElement, sectionLabel: string, hex: string) {
|
||||
fireEvent.click(must(section(c, sectionLabel).querySelector(`[aria-label="${hex}"]`)))
|
||||
}
|
||||
|
||||
/** One of several: a chip that reads as chosen. */
|
||||
function pick(c: HTMLElement, label: string) {
|
||||
const chip = [...c.querySelectorAll('[role="radio"]')].find(b => b.textContent === label)
|
||||
@@ -225,11 +248,15 @@ describe('a text element', () => {
|
||||
expect(el()).toMatchObject({ weight: 600 })
|
||||
})
|
||||
|
||||
/* The four alignments are named in the reader's language, not by their raw values. */
|
||||
it('sets the alignment', () => {
|
||||
load([text()])
|
||||
const { container } = draw()
|
||||
fireEvent.click(container.querySelector('[aria-label="right"]')!)
|
||||
expect(container.querySelector('[aria-label="right"]')).toBeNull()
|
||||
fireEvent.click(must(container.querySelector('[aria-label="journey.studio.align.right"]')))
|
||||
expect(el()).toMatchObject({ align: 'right' })
|
||||
fireEvent.click(must(container.querySelector('[aria-label="journey.studio.align.justify"]')))
|
||||
expect(el()).toMatchObject({ align: 'justify' })
|
||||
})
|
||||
|
||||
/* Italic was drawn by the renderer and had no control at all before. */
|
||||
@@ -258,7 +285,7 @@ describe('a text element', () => {
|
||||
it('sets the colour', () => {
|
||||
load([text()])
|
||||
const { container } = draw()
|
||||
pickSwatch(container, 'journey.studio.text', '#9f1239')
|
||||
pickSwatchIn(container, 'journey.studio.colour', '#9f1239')
|
||||
expect(el()).toMatchObject({ color: '#9f1239' })
|
||||
})
|
||||
|
||||
@@ -357,10 +384,12 @@ describe('a photo frame', () => {
|
||||
* the page it was meant to carry.
|
||||
*/
|
||||
describe('filling a page with a picture', () => {
|
||||
/** The chips live in Arrange, which starts folded like the frame's numbers. */
|
||||
/** The chips sit under the crop, open from the start: the commonest layout is one press away. */
|
||||
function fillChip(c: HTMLElement, label: string): HTMLElement | undefined {
|
||||
open(c, 'journey.studio.arrange')
|
||||
return [...c.querySelectorAll('.st-chip')].find(b => b.textContent === label) as HTMLElement | undefined
|
||||
const crop = [...c.querySelectorAll<HTMLElement>('.st-section')]
|
||||
.find(s => s.querySelector('.st-section-label')?.textContent === 'journey.studio.crop')
|
||||
return [...(crop?.querySelectorAll('.st-chip') ?? [])]
|
||||
.find(b => b.textContent === label) as HTMLElement | undefined
|
||||
}
|
||||
|
||||
it('reaches the bleed on every side and goes behind everything else', () => {
|
||||
@@ -443,10 +472,13 @@ describe('a shape', () => {
|
||||
load([shape({ fill: '#111827', stroke: null, strokeWidth: 0 })])
|
||||
const { container } = draw()
|
||||
|
||||
pickSwatch(container, 'journey.studio.fill', '#0f766e')
|
||||
pickSwatchIn(container, 'journey.studio.fill', '#0f766e')
|
||||
expect(el()).toMatchObject({ fill: '#0f766e', stroke: null })
|
||||
|
||||
pickSwatch(container, 'journey.studio.stroke', '#1e3a8a')
|
||||
// The outline is folded on a shape that has none, and opens when asked.
|
||||
expect(head(container, 'journey.studio.stroke').getAttribute('aria-expanded')).toBe('false')
|
||||
open(container, 'journey.studio.stroke')
|
||||
pickSwatchIn(container, 'journey.studio.stroke', '#1e3a8a')
|
||||
// A colour with no width would draw nothing, so the line gets one.
|
||||
expect(el()).toMatchObject({ fill: '#0f766e', stroke: '#1e3a8a', strokeWidth: 0.5 })
|
||||
})
|
||||
@@ -526,6 +558,13 @@ describe('an icon', () => {
|
||||
expect(el()).toMatchObject({ name: 'Compass', color: '#9f1239', frame: FRAME })
|
||||
})
|
||||
|
||||
it('sets its colour under Look', () => {
|
||||
load([icon()])
|
||||
const { container } = draw()
|
||||
pickSwatchIn(container, 'journey.studio.look', '#0f766e')
|
||||
expect(el()).toMatchObject({ color: '#0f766e' })
|
||||
})
|
||||
|
||||
it('sets how heavy the drawing is', () => {
|
||||
load([icon()])
|
||||
const { container } = draw()
|
||||
|
||||
@@ -123,9 +123,18 @@ function section(container: HTMLElement, label: string): HTMLElement {
|
||||
return found
|
||||
}
|
||||
|
||||
/** Some sections start folded, so a control inside one has to be uncovered. */
|
||||
/** Whether a section is open, read off its head. */
|
||||
function isOpen(container: HTMLElement, label: string): boolean {
|
||||
return section(container, label).querySelector('.st-section-head')?.getAttribute('aria-expanded') === 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* Some sections start folded, so a control inside one has to be uncovered. A
|
||||
* folded section opens by itself on an element that already uses it, and is
|
||||
* left open then.
|
||||
*/
|
||||
function open(container: HTMLElement, label: string) {
|
||||
fireEvent.click(section(container, label).querySelector('.st-section-head')!)
|
||||
if (!isOpen(container, label)) fireEvent.click(section(container, label).querySelector('.st-section-head')!)
|
||||
}
|
||||
|
||||
/** The control side of one labelled line inside one section. */
|
||||
@@ -225,6 +234,17 @@ describe('the route map', () => {
|
||||
expect(saved().clip).toBe('rect')
|
||||
})
|
||||
|
||||
it('folds the zoom of a map that fits its route, and opens it on one held at a zoom', () => {
|
||||
const tiles = { source: 'tiles', tileUrl: 'https://tile.example/{z}/{x}/{y}.png' }
|
||||
const fitted = show(mapEl(tiles))
|
||||
expect(isOpen(fitted.container, 'journey.studio.mapZoom')).toBe(false)
|
||||
fitted.unmount()
|
||||
|
||||
const held = show(mapEl({ ...tiles, zoom: 11 }))
|
||||
expect(isOpen(held.container, 'journey.studio.mapZoom')).toBe(true)
|
||||
expect(screen.getByRole('radio', { name: '11' })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('writes a zoom from the folded group, and hands it back automatically', () => {
|
||||
const { container } = show(mapEl({ source: 'tiles', tileUrl: 'https://tile.example/{z}/{x}/{y}.png' }))
|
||||
open(container, 'journey.studio.mapZoom')
|
||||
@@ -532,6 +552,18 @@ describe('the tail all five share', () => {
|
||||
expect(saved().weight).toBe(400)
|
||||
})
|
||||
|
||||
it('folds the type of an element set as placed, and opens it on one set otherwise', () => {
|
||||
const plain = show(statsEl())
|
||||
expect(isOpen(plain.container, 'journey.studio.typography')).toBe(false)
|
||||
plain.unmount()
|
||||
|
||||
for (const over of [{ font: 'serif' }, { weight: 500 }, { textScale: 1.5 }]) {
|
||||
const view = show(statsEl(over))
|
||||
expect(isOpen(view.container, 'journey.studio.typography')).toBe(true)
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a weight the new family does have', () => {
|
||||
const { container } = show(statsEl({ font: 'sans', weight: 500 }))
|
||||
open(container, 'journey.studio.typography')
|
||||
|
||||
@@ -60,8 +60,8 @@ function open(journeyStats: JourneyStats, over: { canEdit?: boolean; onToggleSto
|
||||
return { onToggleStop, head }
|
||||
}
|
||||
|
||||
const rows = () => Array.from(document.querySelectorAll('.st-stop'))
|
||||
const labels = () => rows().map(r => r.querySelector('.st-stop-label')?.textContent)
|
||||
const rows = () => Array.from(document.querySelectorAll('.st-route-stop'))
|
||||
const labels = () => rows().map(r => r.querySelector('.st-route-stop-label')?.textContent)
|
||||
const switches = () => screen.queryAllByRole('button', { name: 'journey.studio.stopToggle' })
|
||||
/** The switch of a row reads its state from the title, since it draws a mark. */
|
||||
const stateOf = (row: Element) => row.querySelector('.st-chip')?.getAttribute('title') ?? ''
|
||||
|
||||
@@ -63,6 +63,7 @@ import storage from './storage';
|
||||
import studioFonts from './studioFonts';
|
||||
import studioLayers from './studioLayers';
|
||||
import studioPaint from './studioPaint';
|
||||
import studioPanels from './studioPanels';
|
||||
import studioPhoto from './studioPhoto';
|
||||
import studioShapes from './studioShapes';
|
||||
import studioStickers from './studioStickers';
|
||||
@@ -166,5 +167,6 @@ const locale = {
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
...studioTemplates,
|
||||
...studioPanels,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -317,9 +317,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Square 30 × 30 cm', // en-fallback
|
||||
'journey.studio.pages': 'Pages', // en-fallback
|
||||
'journey.studio.cover': 'Cover', // en-fallback
|
||||
'journey.studio.inspector': 'Properties', // en-fallback
|
||||
'journey.studio.inspectorEmpty': 'Select something on the page to edit it.', // en-fallback
|
||||
'journey.studio.emptySpread': 'This spread is still empty', // en-fallback
|
||||
'journey.studio.autoLayout': 'Auto layout', // en-fallback
|
||||
'journey.studio.export': 'Export', // en-fallback
|
||||
'journey.studio.day': 'اليوم {day}',
|
||||
@@ -425,8 +423,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.sampleSubheading': 'A subheading', // en-fallback
|
||||
'journey.studio.sampleBody': 'Write something about this day.', // en-fallback
|
||||
'journey.studio.sampleCaption': 'Caption', // en-fallback
|
||||
'journey.studio.templatesCoverHint':
|
||||
'Layouts apply to the inside spreads. The cover and the back are designed on their own.', // en-fallback
|
||||
'journey.studio.tpl.heroStory': 'Hero and story', // en-fallback
|
||||
'journey.studio.tpl.fullBleed': 'One picture, full spread', // en-fallback
|
||||
'journey.studio.tpl.twoUp': 'Two full pages', // en-fallback
|
||||
@@ -438,10 +434,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.dropPhotoHere': 'اسحب صورتك\nوأفلتها هنا',
|
||||
'journey.studio.searchContent': 'Search photos and entries', // en-fallback
|
||||
'journey.studio.noMatches': 'Nothing matches that.', // en-fallback
|
||||
'journey.studio.decorations': 'Decoration', // en-fallback
|
||||
'journey.studio.quoteMark': 'Quotation mark', // en-fallback
|
||||
'journey.studio.circleOutline': 'Outlined circle', // en-fallback
|
||||
'journey.studio.roundFrame': 'Rounded frame', // en-fallback
|
||||
'journey.studio.shapeKind.rounded': 'Rounded rectangle', // en-fallback
|
||||
'journey.studio.shapeKind.triangle': 'Triangle', // en-fallback
|
||||
'journey.studio.shapeKind.outline': 'Outline only', // en-fallback
|
||||
@@ -467,17 +460,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeGroup.decor': 'زخرفة',
|
||||
'journey.studio.shapeGroup.banners': 'لافتات',
|
||||
'journey.studio.summary': 'الملخص',
|
||||
'journey.studio.tripSummary': 'ملخص الرحلة',
|
||||
'journey.studio.statsRow': 'صف واحد',
|
||||
'journey.studio.statsFull': 'كل شيء',
|
||||
'journey.studio.routeMap': 'خريطة المسار',
|
||||
'journey.studio.mapStyle.minimal': 'بسيط',
|
||||
'journey.studio.mapStyle.outline': 'مخطط',
|
||||
'journey.studio.mapStyle.paper': 'ورقي',
|
||||
'journey.studio.mapStyle.dark': 'داكن',
|
||||
'journey.studio.countries': 'الدول',
|
||||
'journey.studio.countryList': 'قائمة الدول',
|
||||
'journey.studio.countryGrid': 'شبكة الدول',
|
||||
'journey.studio.noCountries': 'لم تُحدَّد دول لهذه الرحلة بعد.',
|
||||
'journey.studio.noRoute': 'لا توجد محطات بإحداثيات بعد.',
|
||||
'journey.studio.marks': 'العلامات',
|
||||
@@ -588,8 +576,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatCustom': 'مقاس مخصص',
|
||||
'journey.studio.document': 'المستند',
|
||||
'journey.studio.pageNumbers': 'أرقام الصفحات',
|
||||
'journey.studio.pageNumbersOn': 'تشغيل',
|
||||
'journey.studio.pageNumbersOff': 'إيقاف',
|
||||
'journey.studio.folio.outer': 'خارجي',
|
||||
'journey.studio.folio.inner': 'داخلي',
|
||||
'journey.studio.folio.centre': 'وسط',
|
||||
@@ -629,14 +615,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.pinPhoto': 'صور',
|
||||
'journey.studio.pinPhotoNone': 'لا توجد صور بعد على هذه المحطات، لذا تُرسم كنقاط.',
|
||||
'journey.studio.roads': 'الطرق',
|
||||
'journey.studio.roadsFetch': 'اتبع الطرق',
|
||||
'journey.studio.roadsFollow': 'عبر الطرق',
|
||||
'journey.studio.roadsDirect': 'خط مباشر',
|
||||
'journey.studio.recommended': 'موصى به',
|
||||
'journey.studio.bleed': 'الفيض',
|
||||
'journey.studio.safeArea': 'الآمنة',
|
||||
'journey.studio.roadsAgain': 'جلب من جديد',
|
||||
'journey.studio.roadsClear': 'مسح',
|
||||
'journey.studio.roadsBusy': 'جارٍ الجلب',
|
||||
'journey.studio.roadsHint': 'اطلب من خدمة توجيه الطريق الذي قُطعت به كل مرحلة. المراحل الطويلة تبقى كما هي.',
|
||||
'journey.studio.roadsHave': 'تُحفظ الطرق في هذا الكتاب، فيُطبع الخط نفسه دون اتصال.',
|
||||
@@ -661,8 +645,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapClipRect': 'داخل إطار',
|
||||
'journey.studio.mapClipCountry': 'مقصوص',
|
||||
'journey.studio.mapClipNeedsCountry': 'يحتاج بلدا ليقص وفقه',
|
||||
'journey.studio.mapCutVector': 'مقصوص',
|
||||
'journey.studio.mapCutTiles': 'خريطة مقصوصة',
|
||||
'journey.studio.mapZoomAuto': 'ملائم',
|
||||
'journey.studio.saving': 'يجري الحفظ',
|
||||
'journey.studio.saved': 'تم الحفظ',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's two side panels: the rail, the inspector's arrange row, the theme when nothing is selected. */
|
||||
const studioPanels: TranslationStrings = {
|
||||
'journey.studio.align.justify': 'ضبط',
|
||||
'journey.studio.order': 'الترتيب',
|
||||
'journey.studio.showLayers': 'العرض في الطبقات',
|
||||
'studio.theme.choose': 'اختيار سمة',
|
||||
'studio.theme.change': 'تغيير السمة',
|
||||
};
|
||||
|
||||
export default studioPanels;
|
||||
@@ -11,7 +11,6 @@ const studioStickers: TranslationStrings = {
|
||||
'journey.studio.stickerSlot': 'اللون {index}',
|
||||
'journey.studio.stickerResetColours': 'الألوان الأصلية',
|
||||
'journey.studio.stickerMissing': 'هذا الملصق غير موجود في مكتبة هذا الإصدار من TREK.',
|
||||
'journey.studio.stickerPlacement': 'الموضع',
|
||||
'journey.studio.stickerFlip': 'عكس أفقي',
|
||||
'journey.studio.stickerTurnLeft': 'تدوير لليسار',
|
||||
'journey.studio.stickerTurnRight': 'تدوير لليمين',
|
||||
|
||||
@@ -63,6 +63,7 @@ import storage from './storage';
|
||||
import studioFonts from './studioFonts';
|
||||
import studioLayers from './studioLayers';
|
||||
import studioPaint from './studioPaint';
|
||||
import studioPanels from './studioPanels';
|
||||
import studioPhoto from './studioPhoto';
|
||||
import studioShapes from './studioShapes';
|
||||
import studioStickers from './studioStickers';
|
||||
@@ -166,5 +167,6 @@ const locale = {
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
...studioTemplates,
|
||||
...studioPanels,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -319,9 +319,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Kvadrat 30 × 30 sm',
|
||||
'journey.studio.pages': 'Səhifələr',
|
||||
'journey.studio.cover': 'Üzlük',
|
||||
'journey.studio.inspector': 'Xüsusiyyətlər',
|
||||
'journey.studio.inspectorEmpty': 'Redaktə etmək üçün səhifədə bir element seçin.',
|
||||
'journey.studio.emptySpread': 'Bu açılım hələ boşdur',
|
||||
'journey.studio.autoLayout': 'Avtomatik tərtibat',
|
||||
'journey.studio.export': 'İxrac et',
|
||||
'journey.studio.day': 'GÜN {day}',
|
||||
@@ -432,8 +430,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.sampleBody': 'Bu gün haqqında bir şey yazın.',
|
||||
'journey.studio.sampleCaption': 'Fotoaltı yazı',
|
||||
|
||||
'journey.studio.templatesCoverHint':
|
||||
'Tərtibatlar daxili açılımlara tətbiq edilir. Ön və arxa üzlüklər ayrıca dizayn olunur.',
|
||||
'journey.studio.tpl.heroStory': 'Baş foto və hekayə',
|
||||
'journey.studio.tpl.fullBleed': 'Bir foto, tam açılım',
|
||||
'journey.studio.tpl.twoUp': 'İki tam səhifə',
|
||||
@@ -446,10 +442,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.searchContent': 'Foto və qeydləri axtarın',
|
||||
'journey.studio.noMatches': 'Uyğun nəticə yoxdur.',
|
||||
|
||||
'journey.studio.decorations': 'Dekorasiya',
|
||||
'journey.studio.quoteMark': 'Dırnaq işarəsi',
|
||||
'journey.studio.circleOutline': 'Konturlu dairə',
|
||||
'journey.studio.roundFrame': 'Yumru çərçivə',
|
||||
'journey.studio.shapeKind.rounded': 'Yumru künclü düzbucaqlı',
|
||||
'journey.studio.shapeKind.triangle': 'Üçbucaq',
|
||||
'journey.studio.shapeKind.outline': 'Yalnız kontur',
|
||||
@@ -479,9 +472,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeGroup.banners': 'Bannerlər',
|
||||
|
||||
'journey.studio.summary': 'Xülasə',
|
||||
'journey.studio.tripSummary': 'Səyahət xülasəsi',
|
||||
'journey.studio.statsRow': 'Bir sıra',
|
||||
'journey.studio.statsFull': 'Hər şey',
|
||||
'journey.studio.routeMap': 'Marşrut xəritəsi',
|
||||
'journey.studio.mapStyle.minimal': 'Minimal',
|
||||
'journey.studio.mapStyle.outline': 'Kontur',
|
||||
@@ -489,8 +479,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapStyle.dark': 'Tünd',
|
||||
|
||||
'journey.studio.countries': 'Ölkələr',
|
||||
'journey.studio.countryList': 'Ölkə siyahısı',
|
||||
'journey.studio.countryGrid': 'Ölkə toru',
|
||||
'journey.studio.noCountries': 'Bu səyahət üçün hələ ölkə müəyyən edilməyib.',
|
||||
'journey.studio.noRoute': 'Hələ koordinatları olan dayanacaq yoxdur.',
|
||||
|
||||
@@ -613,8 +601,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatCustom': 'Fərdi ölçü',
|
||||
'journey.studio.document': 'Sənəd',
|
||||
'journey.studio.pageNumbers': 'Səhifə nömrələri',
|
||||
'journey.studio.pageNumbersOn': 'Aktiv',
|
||||
'journey.studio.pageNumbersOff': 'Deaktiv',
|
||||
'journey.studio.folio.outer': 'Xarici',
|
||||
'journey.studio.folio.inner': 'Daxili',
|
||||
'journey.studio.folio.centre': 'Mərkəzdə',
|
||||
@@ -643,11 +629,9 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapSourceRelief': 'Relyef',
|
||||
'journey.studio.routeLook': 'Marşrut xətti',
|
||||
'journey.studio.roads': 'Yollar',
|
||||
'journey.studio.roadsFetch': 'Yolları izlə',
|
||||
'journey.studio.roadsFollow': 'Yollar üzrə',
|
||||
'journey.studio.roadsDirect': 'Birbaşa',
|
||||
'journey.studio.roadsAgain': 'Yenidən əldə et',
|
||||
'journey.studio.roadsClear': 'Təmizlə',
|
||||
'journey.studio.roadsBusy': 'Sorğu göndərilir',
|
||||
'journey.studio.roadsHint':
|
||||
'Hər marşrut hissəsinin avtomobillə keçilən yolunu marşrut xidmətindən əldə edin. Uzun hissələr olduğu kimi qalacaq.',
|
||||
@@ -694,8 +678,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapClipRect': 'Çərçivə daxilində',
|
||||
'journey.studio.mapClipCountry': 'Formaya görə kəs',
|
||||
'journey.studio.mapClipNeedsCountry': 'Formaya görə kəsmək üçün ölkə tələb olunur',
|
||||
'journey.studio.mapCutVector': 'Kəsilmiş forma',
|
||||
'journey.studio.mapCutTiles': 'Kəsilmiş xəritə',
|
||||
'journey.studio.mapZoomAuto': 'Sığdır',
|
||||
|
||||
'journey.studio.saving': 'Yadda saxlanılır',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's two side panels: the rail, the inspector's arrange row, the theme when nothing is selected. */
|
||||
const studioPanels: TranslationStrings = {
|
||||
'journey.studio.align.justify': 'İki tərəfə düzləndirilmiş',
|
||||
'journey.studio.order': 'Sıra',
|
||||
'journey.studio.showLayers': 'Laylarda göstər',
|
||||
'studio.theme.choose': 'Mövzu seçin',
|
||||
'studio.theme.change': 'Mövzunu dəyişin',
|
||||
};
|
||||
|
||||
export default studioPanels;
|
||||
@@ -12,7 +12,6 @@ const studioStickers: TranslationStrings = {
|
||||
'journey.studio.stickerSlot': 'Rəng {index}',
|
||||
'journey.studio.stickerResetColours': 'Orijinal rənglər',
|
||||
'journey.studio.stickerMissing': 'Bu stiker TREK-in bu versiyasının kitabxanasında yoxdur.',
|
||||
'journey.studio.stickerPlacement': 'Yerləşmə',
|
||||
'journey.studio.stickerFlip': 'Güzgü kimi çevir',
|
||||
'journey.studio.stickerTurnLeft': 'Sola döndər',
|
||||
'journey.studio.stickerTurnRight': 'Sağa döndər',
|
||||
|
||||
@@ -63,6 +63,7 @@ import storage from './storage';
|
||||
import studioFonts from './studioFonts';
|
||||
import studioLayers from './studioLayers';
|
||||
import studioPaint from './studioPaint';
|
||||
import studioPanels from './studioPanels';
|
||||
import studioPhoto from './studioPhoto';
|
||||
import studioShapes from './studioShapes';
|
||||
import studioStickers from './studioStickers';
|
||||
@@ -166,5 +167,6 @@ const locale = {
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
...studioTemplates,
|
||||
...studioPanels,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -295,9 +295,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Square 30 × 30 cm', // en-fallback
|
||||
'journey.studio.pages': 'Pages', // en-fallback
|
||||
'journey.studio.cover': 'Cover', // en-fallback
|
||||
'journey.studio.inspector': 'Properties', // en-fallback
|
||||
'journey.studio.inspectorEmpty': 'Select something on the page to edit it.', // en-fallback
|
||||
'journey.studio.emptySpread': 'This spread is still empty', // en-fallback
|
||||
'journey.studio.autoLayout': 'Auto layout', // en-fallback
|
||||
'journey.studio.export': 'Export', // en-fallback
|
||||
'journey.studio.day': 'DIA {day}',
|
||||
@@ -398,8 +396,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.sampleSubheading': 'A subheading', // en-fallback
|
||||
'journey.studio.sampleBody': 'Write something about this day.', // en-fallback
|
||||
'journey.studio.sampleCaption': 'Caption', // en-fallback
|
||||
'journey.studio.templatesCoverHint':
|
||||
'Layouts apply to the inside spreads. The cover and the back are designed on their own.', // en-fallback
|
||||
'journey.studio.tpl.heroStory': 'Hero and story', // en-fallback
|
||||
'journey.studio.tpl.fullBleed': 'One picture, full spread', // en-fallback
|
||||
'journey.studio.tpl.twoUp': 'Two full pages', // en-fallback
|
||||
@@ -411,10 +407,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.dropPhotoHere': 'Arraste sua foto\npara cá',
|
||||
'journey.studio.searchContent': 'Search photos and entries', // en-fallback
|
||||
'journey.studio.noMatches': 'Nothing matches that.', // en-fallback
|
||||
'journey.studio.decorations': 'Decoration', // en-fallback
|
||||
'journey.studio.quoteMark': 'Quotation mark', // en-fallback
|
||||
'journey.studio.circleOutline': 'Outlined circle', // en-fallback
|
||||
'journey.studio.roundFrame': 'Rounded frame', // en-fallback
|
||||
'journey.studio.shapeKind.rounded': 'Rounded rectangle', // en-fallback
|
||||
'journey.studio.shapeKind.triangle': 'Triangle', // en-fallback
|
||||
'journey.studio.shapeKind.outline': 'Outline only', // en-fallback
|
||||
@@ -440,17 +433,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeGroup.decor': 'Decoração',
|
||||
'journey.studio.shapeGroup.banners': 'Faixas',
|
||||
'journey.studio.summary': 'Resumo',
|
||||
'journey.studio.tripSummary': 'Resumo da viagem',
|
||||
'journey.studio.statsRow': 'Uma linha',
|
||||
'journey.studio.statsFull': 'Tudo',
|
||||
'journey.studio.routeMap': 'Mapa do trajeto',
|
||||
'journey.studio.mapStyle.minimal': 'Mínimo',
|
||||
'journey.studio.mapStyle.outline': 'Contorno',
|
||||
'journey.studio.mapStyle.paper': 'Papel',
|
||||
'journey.studio.mapStyle.dark': 'Escuro',
|
||||
'journey.studio.countries': 'Países',
|
||||
'journey.studio.countryList': 'Lista de países',
|
||||
'journey.studio.countryGrid': 'Grade de países',
|
||||
'journey.studio.noCountries': 'Nenhum país identificado para esta jornada ainda.',
|
||||
'journey.studio.noRoute': 'Ainda não há paradas com coordenadas.',
|
||||
'journey.studio.marks': 'Marcas',
|
||||
@@ -562,8 +550,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatCustom': 'Tamanho próprio',
|
||||
'journey.studio.document': 'Documento',
|
||||
'journey.studio.pageNumbers': 'Números de página',
|
||||
'journey.studio.pageNumbersOn': 'Sim',
|
||||
'journey.studio.pageNumbersOff': 'Não',
|
||||
'journey.studio.folio.outer': 'Externo',
|
||||
'journey.studio.folio.inner': 'Interno',
|
||||
'journey.studio.folio.centre': 'Centralizado',
|
||||
@@ -589,14 +575,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapSourceRelief': 'Relevo',
|
||||
'journey.studio.routeLook': 'A linha',
|
||||
'journey.studio.roads': 'Estradas',
|
||||
'journey.studio.roadsFetch': 'Seguir as estradas',
|
||||
'journey.studio.roadsFollow': 'Pela estrada',
|
||||
'journey.studio.roadsDirect': 'Direta',
|
||||
'journey.studio.recommended': 'recomendado',
|
||||
'journey.studio.bleed': 'Sangria',
|
||||
'journey.studio.safeArea': 'Segurança',
|
||||
'journey.studio.roadsAgain': 'Buscar de novo',
|
||||
'journey.studio.roadsClear': 'Limpar',
|
||||
'journey.studio.roadsBusy': 'Consultando',
|
||||
'journey.studio.roadsHint':
|
||||
'Pede a um serviço de rotas o caminho percorrido em cada trecho. Os trechos longos ficam como estão.',
|
||||
@@ -636,8 +620,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapClipRect': 'Em moldura',
|
||||
'journey.studio.mapClipCountry': 'Recortado',
|
||||
'journey.studio.mapClipNeedsCountry': 'Precisa de um país para recortar',
|
||||
'journey.studio.mapCutVector': 'Recorte',
|
||||
'journey.studio.mapCutTiles': 'Mapa recortado',
|
||||
'journey.studio.mapZoomAuto': 'Ajustar',
|
||||
'journey.studio.saving': 'Salvando',
|
||||
'journey.studio.saved': 'Salvo',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's two side panels: the rail, the inspector's arrange row, the theme when nothing is selected. */
|
||||
const studioPanels: TranslationStrings = {
|
||||
'journey.studio.align.justify': 'Justificado',
|
||||
'journey.studio.order': 'Ordem',
|
||||
'journey.studio.showLayers': 'Mostrar em Camadas',
|
||||
'studio.theme.choose': 'Escolher um tema',
|
||||
'studio.theme.change': 'Trocar o tema',
|
||||
};
|
||||
|
||||
export default studioPanels;
|
||||
@@ -12,7 +12,6 @@ const studioStickers: TranslationStrings = {
|
||||
'journey.studio.stickerSlot': 'Cor {index}',
|
||||
'journey.studio.stickerResetColours': 'Cores originais',
|
||||
'journey.studio.stickerMissing': 'Este adesivo não está na biblioteca desta versão do TREK.',
|
||||
'journey.studio.stickerPlacement': 'Posição',
|
||||
'journey.studio.stickerFlip': 'Espelhar',
|
||||
'journey.studio.stickerTurnLeft': 'Girar para a esquerda',
|
||||
'journey.studio.stickerTurnRight': 'Girar para a direita',
|
||||
|
||||
@@ -63,6 +63,7 @@ import storage from './storage';
|
||||
import studioFonts from './studioFonts';
|
||||
import studioLayers from './studioLayers';
|
||||
import studioPaint from './studioPaint';
|
||||
import studioPanels from './studioPanels';
|
||||
import studioPhoto from './studioPhoto';
|
||||
import studioShapes from './studioShapes';
|
||||
import studioStickers from './studioStickers';
|
||||
@@ -166,5 +167,6 @@ const locale = {
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
...studioTemplates,
|
||||
...studioPanels,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -294,9 +294,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Square 30 × 30 cm', // en-fallback
|
||||
'journey.studio.pages': 'Pages', // en-fallback
|
||||
'journey.studio.cover': 'Cover', // en-fallback
|
||||
'journey.studio.inspector': 'Properties', // en-fallback
|
||||
'journey.studio.inspectorEmpty': 'Select something on the page to edit it.', // en-fallback
|
||||
'journey.studio.emptySpread': 'This spread is still empty', // en-fallback
|
||||
'journey.studio.autoLayout': 'Auto layout', // en-fallback
|
||||
'journey.studio.export': 'Export', // en-fallback
|
||||
'journey.studio.day': 'DIA {day}',
|
||||
@@ -397,8 +395,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.sampleSubheading': 'A subheading', // en-fallback
|
||||
'journey.studio.sampleBody': 'Write something about this day.', // en-fallback
|
||||
'journey.studio.sampleCaption': 'Caption', // en-fallback
|
||||
'journey.studio.templatesCoverHint':
|
||||
'Layouts apply to the inside spreads. The cover and the back are designed on their own.', // en-fallback
|
||||
'journey.studio.tpl.heroStory': 'Hero and story', // en-fallback
|
||||
'journey.studio.tpl.fullBleed': 'One picture, full spread', // en-fallback
|
||||
'journey.studio.tpl.twoUp': 'Two full pages', // en-fallback
|
||||
@@ -410,10 +406,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.dropPhotoHere': 'Arrossega la teva foto\naquí',
|
||||
'journey.studio.searchContent': 'Search photos and entries', // en-fallback
|
||||
'journey.studio.noMatches': 'Nothing matches that.', // en-fallback
|
||||
'journey.studio.decorations': 'Decoration', // en-fallback
|
||||
'journey.studio.quoteMark': 'Quotation mark', // en-fallback
|
||||
'journey.studio.circleOutline': 'Outlined circle', // en-fallback
|
||||
'journey.studio.roundFrame': 'Rounded frame', // en-fallback
|
||||
'journey.studio.shapeKind.rounded': 'Rounded rectangle', // en-fallback
|
||||
'journey.studio.shapeKind.triangle': 'Triangle', // en-fallback
|
||||
'journey.studio.shapeKind.outline': 'Outline only', // en-fallback
|
||||
@@ -439,17 +432,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeGroup.decor': 'Decoració',
|
||||
'journey.studio.shapeGroup.banners': 'Banderoles',
|
||||
'journey.studio.summary': 'Resum',
|
||||
'journey.studio.tripSummary': 'Resum del viatge',
|
||||
'journey.studio.statsRow': 'Una fila',
|
||||
'journey.studio.statsFull': 'Tot',
|
||||
'journey.studio.routeMap': 'Mapa del recorregut',
|
||||
'journey.studio.mapStyle.minimal': 'Mínim',
|
||||
'journey.studio.mapStyle.outline': 'Contorn',
|
||||
'journey.studio.mapStyle.paper': 'Paper',
|
||||
'journey.studio.mapStyle.dark': 'Fosc',
|
||||
'journey.studio.countries': 'Països',
|
||||
'journey.studio.countryList': 'Llista de països',
|
||||
'journey.studio.countryGrid': 'Graella de països',
|
||||
'journey.studio.noCountries': 'Encara no s’ha determinat cap país per a aquest viatge.',
|
||||
'journey.studio.noRoute': 'Encara no hi ha parades amb coordenades.',
|
||||
'journey.studio.marks': 'Marques',
|
||||
@@ -562,8 +550,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatCustom': 'Mida pròpia',
|
||||
'journey.studio.document': 'Document',
|
||||
'journey.studio.pageNumbers': 'Números de pàgina',
|
||||
'journey.studio.pageNumbersOn': 'Sí',
|
||||
'journey.studio.pageNumbersOff': 'No',
|
||||
'journey.studio.folio.outer': 'Exterior',
|
||||
'journey.studio.folio.inner': 'Interior',
|
||||
'journey.studio.folio.centre': 'Centrat',
|
||||
@@ -603,14 +589,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.pinPhoto': 'Fotos',
|
||||
'journey.studio.pinPhotoNone': 'Encara no hi ha fotos en aquestes parades, així que es dibuixen com a punts.',
|
||||
'journey.studio.roads': 'Carreteres',
|
||||
'journey.studio.roadsFetch': 'Segueix les carreteres',
|
||||
'journey.studio.roadsFollow': 'Per carretera',
|
||||
'journey.studio.roadsDirect': 'Directa',
|
||||
'journey.studio.recommended': 'recomanat',
|
||||
'journey.studio.bleed': 'Sagnat',
|
||||
'journey.studio.safeArea': 'Seguretat',
|
||||
'journey.studio.roadsAgain': 'Torna-ho a demanar',
|
||||
'journey.studio.roadsClear': 'Esborra',
|
||||
'journey.studio.roadsBusy': 'Demanant',
|
||||
'journey.studio.roadsHint':
|
||||
'Demana a un servei de rutes el camí que es va fer a cada tram. Els trams llargs es queden com estan.',
|
||||
@@ -637,8 +621,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapClipRect': 'Amb marc',
|
||||
'journey.studio.mapClipCountry': 'Retallat',
|
||||
'journey.studio.mapClipNeedsCountry': 'Cal un país per retallar',
|
||||
'journey.studio.mapCutVector': 'Retall',
|
||||
'journey.studio.mapCutTiles': 'Mapa retallat',
|
||||
'journey.studio.mapZoomAuto': 'Ajustar',
|
||||
'journey.studio.saving': 'Desant',
|
||||
'journey.studio.saved': 'Desat',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's two side panels: the rail, the inspector's arrange row, the theme when nothing is selected. */
|
||||
const studioPanels: TranslationStrings = {
|
||||
'journey.studio.align.justify': 'Justificat',
|
||||
'journey.studio.order': 'Ordre',
|
||||
'journey.studio.showLayers': 'Mostra a Capes',
|
||||
'studio.theme.choose': 'Tria un tema',
|
||||
'studio.theme.change': 'Canvia el tema',
|
||||
};
|
||||
|
||||
export default studioPanels;
|
||||
@@ -12,7 +12,6 @@ const studioStickers: TranslationStrings = {
|
||||
'journey.studio.stickerSlot': 'Color {index}',
|
||||
'journey.studio.stickerResetColours': 'Colors originals',
|
||||
'journey.studio.stickerMissing': 'Aquest adhesiu no és a la biblioteca d’aquesta versió de TREK.',
|
||||
'journey.studio.stickerPlacement': 'Col·locació',
|
||||
'journey.studio.stickerFlip': 'Emmiralla',
|
||||
'journey.studio.stickerTurnLeft': 'Gira a l’esquerra',
|
||||
'journey.studio.stickerTurnRight': 'Gira a la dreta',
|
||||
|
||||
@@ -63,6 +63,7 @@ import storage from './storage';
|
||||
import studioFonts from './studioFonts';
|
||||
import studioLayers from './studioLayers';
|
||||
import studioPaint from './studioPaint';
|
||||
import studioPanels from './studioPanels';
|
||||
import studioPhoto from './studioPhoto';
|
||||
import studioShapes from './studioShapes';
|
||||
import studioStickers from './studioStickers';
|
||||
@@ -166,5 +167,6 @@ const locale = {
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
...studioTemplates,
|
||||
...studioPanels,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -299,9 +299,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Square 30 × 30 cm', // en-fallback
|
||||
'journey.studio.pages': 'Pages', // en-fallback
|
||||
'journey.studio.cover': 'Cover', // en-fallback
|
||||
'journey.studio.inspector': 'Properties', // en-fallback
|
||||
'journey.studio.inspectorEmpty': 'Select something on the page to edit it.', // en-fallback
|
||||
'journey.studio.emptySpread': 'This spread is still empty', // en-fallback
|
||||
'journey.studio.autoLayout': 'Auto layout', // en-fallback
|
||||
'journey.studio.export': 'Export', // en-fallback
|
||||
'journey.studio.day': 'DEN {day}',
|
||||
@@ -402,8 +400,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.sampleSubheading': 'A subheading', // en-fallback
|
||||
'journey.studio.sampleBody': 'Write something about this day.', // en-fallback
|
||||
'journey.studio.sampleCaption': 'Caption', // en-fallback
|
||||
'journey.studio.templatesCoverHint':
|
||||
'Layouts apply to the inside spreads. The cover and the back are designed on their own.', // en-fallback
|
||||
'journey.studio.tpl.heroStory': 'Hero and story', // en-fallback
|
||||
'journey.studio.tpl.fullBleed': 'One picture, full spread', // en-fallback
|
||||
'journey.studio.tpl.twoUp': 'Two full pages', // en-fallback
|
||||
@@ -415,10 +411,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.dropPhotoHere': 'Přetáhni sem\nsvou fotku',
|
||||
'journey.studio.searchContent': 'Search photos and entries', // en-fallback
|
||||
'journey.studio.noMatches': 'Nothing matches that.', // en-fallback
|
||||
'journey.studio.decorations': 'Decoration', // en-fallback
|
||||
'journey.studio.quoteMark': 'Quotation mark', // en-fallback
|
||||
'journey.studio.circleOutline': 'Outlined circle', // en-fallback
|
||||
'journey.studio.roundFrame': 'Rounded frame', // en-fallback
|
||||
'journey.studio.shapeKind.rounded': 'Rounded rectangle', // en-fallback
|
||||
'journey.studio.shapeKind.triangle': 'Triangle', // en-fallback
|
||||
'journey.studio.shapeKind.outline': 'Outline only', // en-fallback
|
||||
@@ -444,17 +437,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeGroup.decor': 'Dekorace',
|
||||
'journey.studio.shapeGroup.banners': 'Bannery',
|
||||
'journey.studio.summary': 'Přehled',
|
||||
'journey.studio.tripSummary': 'Přehled cesty',
|
||||
'journey.studio.statsRow': 'Jeden řádek',
|
||||
'journey.studio.statsFull': 'Vše',
|
||||
'journey.studio.routeMap': 'Mapa trasy',
|
||||
'journey.studio.mapStyle.minimal': 'Minimální',
|
||||
'journey.studio.mapStyle.outline': 'Obrys',
|
||||
'journey.studio.mapStyle.paper': 'Papír',
|
||||
'journey.studio.mapStyle.dark': 'Tmavá',
|
||||
'journey.studio.countries': 'Země',
|
||||
'journey.studio.countryList': 'Seznam zemí',
|
||||
'journey.studio.countryGrid': 'Mřížka zemí',
|
||||
'journey.studio.noCountries': 'Pro tuto cestu zatím nebyly určeny žádné země.',
|
||||
'journey.studio.noRoute': 'Zatím žádné zastávky se souřadnicemi.',
|
||||
'journey.studio.marks': 'Značky',
|
||||
@@ -566,8 +554,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatCustom': 'Vlastní formát',
|
||||
'journey.studio.document': 'Dokument',
|
||||
'journey.studio.pageNumbers': 'Čísla stran',
|
||||
'journey.studio.pageNumbersOn': 'Zapnuto',
|
||||
'journey.studio.pageNumbersOff': 'Vypnuto',
|
||||
'journey.studio.folio.outer': 'Vnější',
|
||||
'journey.studio.folio.inner': 'Vnitřní',
|
||||
'journey.studio.folio.centre': 'Na střed',
|
||||
@@ -593,14 +579,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapSourceRelief': 'Reliéf',
|
||||
'journey.studio.routeLook': 'Čára',
|
||||
'journey.studio.roads': 'Silnice',
|
||||
'journey.studio.roadsFetch': 'Vést po silnicích',
|
||||
'journey.studio.roadsFollow': 'Po silnicích',
|
||||
'journey.studio.roadsDirect': 'Vzdušnou čarou',
|
||||
'journey.studio.recommended': 'doporučeno',
|
||||
'journey.studio.bleed': 'Spadávka',
|
||||
'journey.studio.safeArea': 'Bezpečná',
|
||||
'journey.studio.roadsAgain': 'Načíst znovu',
|
||||
'journey.studio.roadsClear': 'Vymazat',
|
||||
'journey.studio.roadsBusy': 'Dotazování',
|
||||
'journey.studio.roadsHint': 'Zeptá se plánovače tras, kudy každý úsek vedl. Dlouhé úseky zůstanou beze změny.',
|
||||
'journey.studio.roadsHave': 'Silnice jsou uložené v této knize, takže se stejná čára vytiskne i offline.',
|
||||
@@ -639,8 +623,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapClipRect': 'V rámu',
|
||||
'journey.studio.mapClipCountry': 'Vyříznuto',
|
||||
'journey.studio.mapClipNeedsCountry': 'K vyříznutí je potřeba země',
|
||||
'journey.studio.mapCutVector': 'Výřez',
|
||||
'journey.studio.mapCutTiles': 'Vyříznutá mapa',
|
||||
'journey.studio.mapZoomAuto': 'Podle obsahu',
|
||||
'journey.studio.saving': 'Ukládání',
|
||||
'journey.studio.saved': 'Uloženo',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's two side panels: the rail, the inspector's arrange row, the theme when nothing is selected. */
|
||||
const studioPanels: TranslationStrings = {
|
||||
'journey.studio.align.justify': 'Do bloku',
|
||||
'journey.studio.order': 'Pořadí',
|
||||
'journey.studio.showLayers': 'Zobrazit ve Vrstvách',
|
||||
'studio.theme.choose': 'Vybrat motiv',
|
||||
'studio.theme.change': 'Změnit motiv',
|
||||
};
|
||||
|
||||
export default studioPanels;
|
||||
@@ -11,7 +11,6 @@ const studioStickers: TranslationStrings = {
|
||||
'journey.studio.stickerSlot': 'Barva {index}',
|
||||
'journey.studio.stickerResetColours': 'Původní barvy',
|
||||
'journey.studio.stickerMissing': 'Tato samolepka není v knihovně této verze TREK.',
|
||||
'journey.studio.stickerPlacement': 'Umístění',
|
||||
'journey.studio.stickerFlip': 'Zrcadlit',
|
||||
'journey.studio.stickerTurnLeft': 'Otočit doleva',
|
||||
'journey.studio.stickerTurnRight': 'Otočit doprava',
|
||||
|
||||
@@ -63,6 +63,7 @@ import storage from './storage';
|
||||
import studioFonts from './studioFonts';
|
||||
import studioLayers from './studioLayers';
|
||||
import studioPaint from './studioPaint';
|
||||
import studioPanels from './studioPanels';
|
||||
import studioPhoto from './studioPhoto';
|
||||
import studioShapes from './studioShapes';
|
||||
import studioStickers from './studioStickers';
|
||||
@@ -166,5 +167,6 @@ const locale = {
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
...studioTemplates,
|
||||
...studioPanels,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -295,9 +295,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Quadratisch 30 × 30 cm',
|
||||
'journey.studio.pages': 'Seiten',
|
||||
'journey.studio.cover': 'Cover',
|
||||
'journey.studio.inspector': 'Eigenschaften',
|
||||
'journey.studio.inspectorEmpty': 'Wähle etwas auf der Seite aus, um es zu bearbeiten.',
|
||||
'journey.studio.emptySpread': 'Diese Doppelseite ist noch leer',
|
||||
'journey.studio.autoLayout': 'Automatisch anordnen',
|
||||
'journey.studio.export': 'Exportieren',
|
||||
'journey.studio.day': 'TAG {day}',
|
||||
@@ -398,8 +396,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.sampleSubheading': 'Eine Zwischenüberschrift',
|
||||
'journey.studio.sampleBody': 'Schreib etwas über diesen Tag.',
|
||||
'journey.studio.sampleCaption': 'Bildunterschrift',
|
||||
'journey.studio.templatesCoverHint':
|
||||
'Layouts gelten für die Innenseiten. Cover und Rückseite werden einzeln gestaltet.',
|
||||
'journey.studio.tpl.heroStory': 'Großes Bild mit Text',
|
||||
'journey.studio.tpl.fullBleed': 'Ein Bild über beide Seiten',
|
||||
'journey.studio.tpl.twoUp': 'Zwei volle Seiten',
|
||||
@@ -411,10 +407,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.dropPhotoHere': 'Foto hierher\nziehen',
|
||||
'journey.studio.searchContent': 'Fotos und Einträge suchen',
|
||||
'journey.studio.noMatches': 'Dazu passt nichts.',
|
||||
'journey.studio.decorations': 'Zierrat',
|
||||
'journey.studio.quoteMark': 'Anführungszeichen',
|
||||
'journey.studio.circleOutline': 'Kreis, nur Kontur',
|
||||
'journey.studio.roundFrame': 'Rahmen mit runden Ecken',
|
||||
'journey.studio.shapeKind.rounded': 'Abgerundetes Rechteck',
|
||||
'journey.studio.shapeKind.triangle': 'Dreieck',
|
||||
'journey.studio.shapeKind.outline': 'Nur Kontur',
|
||||
@@ -440,17 +433,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeGroup.decor': 'Deko',
|
||||
'journey.studio.shapeGroup.banners': 'Banner',
|
||||
'journey.studio.summary': 'Übersicht',
|
||||
'journey.studio.tripSummary': 'Reise-Übersicht',
|
||||
'journey.studio.statsRow': 'Eine Zeile',
|
||||
'journey.studio.statsFull': 'Alles',
|
||||
'journey.studio.routeMap': 'Routenkarte',
|
||||
'journey.studio.mapStyle.minimal': 'Reduziert',
|
||||
'journey.studio.mapStyle.outline': 'Umriss',
|
||||
'journey.studio.mapStyle.paper': 'Papier',
|
||||
'journey.studio.mapStyle.dark': 'Dunkel',
|
||||
'journey.studio.countries': 'Länder',
|
||||
'journey.studio.countryList': 'Länderliste',
|
||||
'journey.studio.countryGrid': 'Ländergitter',
|
||||
'journey.studio.noCountries': 'Für diese Reise wurden noch keine Länder ermittelt.',
|
||||
'journey.studio.noRoute': 'Noch keine Stationen mit Koordinaten.',
|
||||
'journey.studio.marks': 'Marken',
|
||||
@@ -563,8 +551,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatCustom': 'Eigenes Format',
|
||||
'journey.studio.document': 'Dokument',
|
||||
'journey.studio.pageNumbers': 'Seitenzahlen',
|
||||
'journey.studio.pageNumbersOn': 'An',
|
||||
'journey.studio.pageNumbersOff': 'Aus',
|
||||
'journey.studio.folio.outer': 'Außen',
|
||||
'journey.studio.folio.inner': 'Innen',
|
||||
'journey.studio.folio.centre': 'Mittig',
|
||||
@@ -604,14 +590,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.pinPhoto': 'Fotos',
|
||||
'journey.studio.pinPhotoNone': 'Diese Stationen haben noch keine Fotos, deshalb werden sie als Punkte gezeichnet.',
|
||||
'journey.studio.roads': 'Straßen',
|
||||
'journey.studio.roadsFetch': 'Den Straßen folgen',
|
||||
'journey.studio.roadsFollow': 'Auf Straßen',
|
||||
'journey.studio.roadsDirect': 'Luftlinie',
|
||||
'journey.studio.recommended': 'empfohlen',
|
||||
'journey.studio.bleed': 'Anschnitt',
|
||||
'journey.studio.safeArea': 'Schutzzone',
|
||||
'journey.studio.roadsAgain': 'Neu abrufen',
|
||||
'journey.studio.roadsClear': 'Verwerfen',
|
||||
'journey.studio.roadsBusy': 'Abfrage',
|
||||
'journey.studio.roadsHint':
|
||||
'Fragt einen Routendienst nach der gefahrenen Strecke jeder Etappe. Lange Etappen bleiben, wie sie sind.',
|
||||
@@ -638,8 +622,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapClipRect': 'Im Rahmen',
|
||||
'journey.studio.mapClipCountry': 'Ausgeschnitten',
|
||||
'journey.studio.mapClipNeedsCountry': 'Braucht ein Land zum Ausschneiden',
|
||||
'journey.studio.mapCutVector': 'Ausschnitt',
|
||||
'journey.studio.mapCutTiles': 'Ausgeschnittene Karte',
|
||||
'journey.studio.mapZoomAuto': 'Passend',
|
||||
'journey.studio.saving': 'Wird gespeichert',
|
||||
'journey.studio.saved': 'Gespeichert',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's two side panels: the rail, the inspector's arrange row, the theme when nothing is selected. */
|
||||
const studioPanels: TranslationStrings = {
|
||||
'journey.studio.align.justify': 'Blocksatz',
|
||||
'journey.studio.order': 'Reihenfolge',
|
||||
'journey.studio.showLayers': 'In Ebenen zeigen',
|
||||
'studio.theme.choose': 'Thema wählen',
|
||||
'studio.theme.change': 'Thema ändern',
|
||||
};
|
||||
|
||||
export default studioPanels;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user