Compare commits

...

24 Commits

Author SHA1 Message Date
Maurice 1487ad97f1 test(trips): the removal names who removed 2026-10-24 08:58:56 +02:00
Maurice fc27b254bb style(i18n): format the German trip strings 2026-10-24 08:38:12 +02:00
Maurice 658536c217 fix(trips): a member another member removed cannot rejoin through the invite link
Removing a member deleted their membership row only, and the invite link
checked nothing but its own token, so whoever kept the link walked straight
back in (GHSA-wr4c-964p-39jg).

trip_member_removals now notes who another member took off a trip. The invite
link refuses them on preview and on accept with a 403 that says so, old link
or regenerated, and the join page shows that instead of the join button.
Adding them back by name, or through a trip-bound invite or a plugin acting
for a member manager, lifts the note. Leaving a trip yourself leaves none, so
the link keeps working for whoever left and for everybody else.
2026-10-24 08:29:51 +02:00
Maurice 00018959c6 fix(studio): a full page takes no new element, the opacity slider commits a move only, a theme draft survives saves
Every add in the Studio (a sticker, a shape, an icon, a text, a frame, a line,
a travel element, a photo from the content panel or a drop), a text style and
a grid of frames appended without looking at the page's cap of 90 elements,
which paste and duplicate already kept. The save then failed with a 400 and
every later save of the book failed the same way. Each of them now leaves a
full page as it is and selects nothing, as a paste does.

The opacity slider committed on every key let go on it, so the Tab that moved
the focus there wrote the value it was last moved to, an empty undo step at
best and another colour's opacity at worst. It commits once after it moved,
on the key or the pointer let go or when the focus leaves.

The Theme panel threw away colours, faces and motifs being tried whenever the
book came back from a save by another editor or the assistant, because the
same theme read anew is a new object. The draft now follows the book's theme
only when the theme itself changed.
2026-10-24 04:19:25 +00:00
Maurice 117af4a8a7 fix(studio): the sheet's keys leave a key to the control that took it, and the layers tree works by keyboard
The sheet listened on the window in the capture phase, before any control saw
a key. Delete on a focused gradient stop removed the whole shape, an arrow on
the angle dial or the radial centre also moved the shape, and Delete, the
arrows and Ctrl+V pressed on a button of the assistant's window acted on the
page although the window stops its keys. The sheet now listens on the
document as the key bubbles, after every control had it and still before the
page's Escape on the window, and leaves a key whose default was prevented. A
gradient stop takes Delete and Backspace even when the last two stops cannot
go.

The layers panel was a tree a keyboard could not walk: only its toggles could
be focused. The tree is now one stop in the Tab order, the row last focused or
else the selected one. Up and Down move along the rows, Home and End to the
ends, Right opens a group or steps into it and Left closes it or steps from a
member up to its group (mirrored right to left), Enter or Space selects a row
as a click does with Shift adding, and F2 renames it. Enter and Escape in the
name field hand the focus back to its row, and the row in focus is ringed. A
hidden or locked layer that cannot be clicked on the page is reached this way.
2026-10-24 04:06:37 +00:00
Maurice 84e18e4954 fix(studio): paste style, the page background, layouts and fill page keep the engine's rules
Paste style wrote the copied fields straight onto the element, although a
pasted look is meant to be the style operation the assistant sends. It now
runs the engine's own restyle (bookElementStyled): a colour or face pasted by
hand takes the element off the theme, so the next theme applied no longer
brings the old colour back; a plain fill replaces the paint of the shape it
lands on instead of staying hidden under it; and a line takes another line's
look without its points, which are its course, not its look.

A background picked by hand in the inspector kept the page's link to the
theme, so the next theme applied set the page back to the theme's colour. The
picker and the background operation now paint through one helper that takes
the link away (withSpreadPaintByHand). Giving one page's background to every
page carries that page's link along, or takes the others' away when it was
painted by hand.

A built-in layout, a book's own layout and laying a page out again from its
entry ignored locks in the Studio: a locked shape was dropped and a locked
photo poured into a slot unlocked, while the assistant's layout operation left
both where they were. All four now lay a page out around its locked elements
through one function (layoutAroundLocks), which the engine's layout operation
uses too.

Fill page and fill spread kept the photo in its group, so the background
picture was selected and dragged with its caption and listed under the group
at the front. It leaves its group as the engine's fill does, and a group left
with one member goes.
2026-10-24 04:06:27 +00:00
Maurice 090e754077 fix(studio): a restore brings in no photo from outside the journey's gallery, letter fills counted
restore_book_layout took any layout the caller sent, so a layout naming a
photo of another journey was kept, and laying a page out on it put that photo
into this book while place_photo refused the same id. The layout is now held
to the rule a page restore states: every photo it names, in a frame, on a map
marker or in the letters of a text, is in the gallery or on a page of the book
already, or the call is refused with photo_not_in_gallery.

restore_book_page and the design apply read the photos of a page in one place
now (book-photo-refs.ts), which counts the photo a text is filled with. A page
put back with letters filled with a photo from outside the gallery is refused
as a frame of it would be, and a designed page whose letter fill photo left
the gallery since is skipped as photo_gone; on a new book's cover the letters
keep their colour instead, as its frame is emptied.

A saved layout keeps no photo of the page it was taken from in its letters, as
it keeps none in its frames, so laying another page out on it no longer
carries that photo along. A layout kept before this fills such letters with
the first photo of the page it is laid on, or leaves them in their colour.

The style operation checks a letter fill photo only when the change sets the
fill, so a title whose photo left the gallery can still be resized or
recoloured, as the text operation already allowed.
2026-10-24 04:05:59 +00:00
Maurice 4322159d8e fix(i18n): the Studio rail and its tabs in every language, and the rail in the help
The names on the rail and in the tab rows the rebuild puts first (Pages,
Content, Layouts, Elements, Shapes, Text, Photos, Entries) were still English
placeholders in twenty one languages. They carry each language's own word now,
and so does the quotation mark, which the Text tab shows as a word under its
tile instead of only in a tooltip. The Frames tab's name was already translated
in those languages and loses the placeholder mark it still carried.
The help on Studio and the wiki describe the rail as it is: seven sections,
Theme and Layers set apart, the stickers under Elements, and the inspector's
order.
2026-10-24 03:08:27 +00:00
Maurice bdf5767a5f 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.
2026-10-24 03:08:27 +00:00
Maurice e8b071aecd fix(tours): restoring a recorded track and saving a copy wait for the tour's queued edits
Both go straight to the server. While an edit of the same tour still waited in
the queue, the restore went out first and its answer replaced the cached edit,
and the edit replayed afterwards planned the track over again. A copy copied
the server's older line instead of the one on screen. Both now wait the way the
actual track writes do: offline, behind a queued write of the tour, or for a
tour the server has not got yet they refuse with OnlineOnlyError, and the
planner says a connection is needed and the waiting changes go first, in all
27 languages.
2026-10-24 02:18:08 +00:00
Maurice f128346a54 fix(tours): the other ways of a leg follow the points of the call that asks for them
The search behind propose_hike_leg_way is kept for a while under the spots of
the hike, but it kept the finished points of the first call. A later call over
the same spots in another form, such as a round trip given with its way back as
its last point, or with other names or stage ends, got the first call's points
back, and its create_with no longer came back to the start. The search now keeps
only the pins that hold each way, and each call sets them on its own points.
2026-10-24 02:18:08 +00:00
Maurice 5bf6af7afc fix(tours): new points of a saved hike take each saved point once and keep its shaping mark
A new point within 25 m of a saved one took that point's key, name and place
link, even when another new point already stood on it. A stop put in just
before a stop a stage starts at took the stage's key, so the stage left its
day and the undo could not bring it back. The saved points are now matched one
to one, the nearest pairs first, so a point standing on a saved one keeps it.

The points a hike is changed through carry no shaping mark, so an inserted or
removed stop and a reshape turned every shaping point into a stop. A via point
standing on a saved shaping point now shapes the line still, unless a stage
ends there.
2026-10-24 02:18:08 +00:00
Maurice 1a941a6f1d fix(tours): a recorded track is neither turned round nor closed by update_tour
A GPX import keeps no points of its own, and the tour read makes up a start
and an end from its line. update_tour with reshape turned or closed those two
made up points, so the recorded line was replaced by a router line between its
ends. A reshape of a tour without points of its own is now refused with the
recorded track wording, before anything is planned or saved.
2026-10-24 02:18:07 +00:00
Maurice 508e17bc11 fix(assistant): the phone chat holds the focus as a dialog, and its lists close on Escape
The full screen chat is a modal dialog but left the focus on the trip behind
it. It now takes the focus when shown, keeps Tab inside and hands the focus
back when set aside or closed, through the helper MSheet now shares. The
dropdown panels take the focus onto their first entry, close on Escape and
give the focus back to their button; the history and menu buttons say
whether their panel is open, and the open chat is announced as current.
2026-10-24 02:06:24 +00:00
Maurice 4d00858b8a fix(assistant): the window's menu and trip list keep the keyboard in reach
The home chat's trip list opened before its trips had loaded, so the focus
stayed on its button and no trip could be reached by keyboard; the first trip
now takes the focus once the list is there. The header's menu says whether it
is open, and closed by Escape or a pick it hands the focus back to its
button, so the next Escape closes the window again. The open chat in the
history list is announced as the current one.
2026-10-24 02:06:24 +00:00
Maurice 7e5b23808d fix(assistant): the home chat leaves a journey's own writes to the journey
The home chat kept every write whose arguments named no trip and no journey,
so the journey group brought update_journey_entry, delete_journey_entry and
reorder_journey_entry_photos (they name only the entry) and the Dawarich
journal entry (it names the journey as journalId). The writes of the
journey's controllers now stay out by their controller, and journalId counts
as a journey, as the home chat's own contract and prompt say.
2026-10-24 01:41:36 +00:00
Maurice bfcc205f05 fix(assistant): a change left waiting past its day no longer holds the next one back
The open changes of a thread are now the ones still inside their decision
window. A call older than a day already reads as expired in the thread and
can no longer be decided, but it stayed approval_requested until the nightly
sweep, so "+ Tour", "+ Day" and "Plan this" answered decide_first with
nothing left to decide, and a decision had to name the expired call too.
listOpen takes the window's start, and every caller passes the same cutoff.
2026-10-24 01:41:36 +00:00
Maurice a6a9f3bbdd fix(deps): multer, undici, nodemailer and fast-uri past the advisories published overnight
multer 2.5.0 (GHSA-q7qp-rjg7-q26m), undici 7.30.1 and 8.12.3 (GHSA-4qv8-2p9j-2685,
GHSA-f72v-6w45-q472, GHSA-xjj6-f33q-929c), nodemailer 10.2.1 (GHSA-8c8m-p3m8-cjg8,
GHSA-m943-m63m-w62w, GHSA-85x2-q946-cp5h) and fast-uri 3.1.9 (GHSA-w74x-v6f9-j3xg and
three more). The server's own ranges and the root overrides hold the fixed versions as
floors, the nested undici of isomorphic-dompurify's jsdom included.
2026-10-24 01:40:57 +00:00
Maurice 5651c11803 feat(assistant): the book designer works from a brief, with archetype covers, lively spreads and stickers
- design_book takes a brief (energy, decoration, collages, theme), a cover
  archetype and a cover lockup, and pages.overrides that give single pages of
  the plan another beat or layout. Directives written before parse as they
  did, and a design asked for none of it is composed exactly as before.
- The engine composes the seven cover archetypes from the book's own photos,
  words, theme faces and colours, lockups, shapes and stickers, varied by the
  seed: spine band, poster pop, cut-out, illustrated postcard, grid and
  badge, minimal serif and stamp collage. A title the directives give a
  display tier is held to it, and the notes say when a band, a badge or a
  stamp holds it smaller or a cover leaves a layout out.
- A lively or loud book turns two in five hero and grid spreads into
  collages, script overlays, badge grids, stamp rows or two-tone splits, the
  first of them always, turns and frames the prints of its other pages, sets
  its cover title larger and takes one or two, or three to six, stickers of
  its theme's motifs a spread, never over words, a photo's detail or the
  fold. A calm book asked for collages gets them and nothing else.
- Every turn the engine gives a print or a sticker stays within what the
  brief's energy takes, a calm book's within its theme's own, so a design is
  never judged against its own brief.
- New checks name a flat cover, a page without a lead, colours or faces off
  the theme and, for a design made with a brief, a book livelier or calmer
  than it asked, on the pages the design made and on a page revised in the
  draft. Page edits answer with the art checks too, and the assistant's
  cards name them in every language.
- list_book_assets lists the shapes, faces, lockups, themes and patterns
  beside the stickers, and the Studio's shape groups and photo frames are one
  list the assistant reads too.
- The Studio prompt gains an index of themes, archetypes and faces and JSON
  recipes of the elements to add and style for each cover and three lively
  spreads, and a turn that designs the book may preview sixteen pages, still
  three images a request; a viewer is told of none of it.
2026-10-24 01:14:03 +00:00
Maurice ff749611c6 feat(studio): the Templates panel offers the decorated templates with a style filter
The Templates section of the rail now opens on the decorated templates of
the page's kind, covers two to a row and spreads at full width, each card
the page renderer drawing the page the click will lay: this page's photos
and words in it, in the book theme, a cover in the journey's title and
subtitle as the assistant's layout sets it. A row of style chips narrows
the set to one archetype. A click is one undo step on the page as it is
then, refused for the other kind of page, and opens the photos when frames
are left empty. The book's own layouts and the built-in wireframes follow
below, and the help guide and the wiki describe the panel as it now is.

The panel moved out of StudioSidebar into its own file; the sidebar keeps the
rail, and its two physical sides went with the code, so the rtl baseline
entry goes too. The Travel panel and a template's map build the journey
they draw with one helper.
2026-10-24 00:36:00 +00:00
Maurice 49f7a447d3 feat(studio): decorated templates, 21 covers and 16 spreads laid on any page in the book theme
A decorated template is a whole designed page kept as data: full document
elements in fractions of the page they were drawn on, with roles for the
photos and words a page brings, links to the theme for its colours and
faces, groups for its lockups (a title with its date line, a badge with its
words, a stamp with its postmark) and an archetype with tags. Seven cover
archetypes in three variants each and sixteen double spreads ship in
shared/src/book/templates/data.

Laying a page out on one pours its photos into the photo roles in order and
its words into the title, subtitle, date, story and caption roles, takes
the words it has none for from the book or the journal entry, sets the
template in the book theme, scales it to the page format with its bleeds
on the bleed of that page, and parks what has no room, a date line the date
mark was not made of included. A text set on a colour is made to read there
at the size it was fitted to, on a band of ink or paper when neither reads.
A mark set as text, such as a large quotation mark, is design to every
layout and never taken for the words of a page.

A fitted text keeps the size it was drawn at as its ceiling, so a shorter
title grows back, and every fitted text is now solved with eight percent of
headroom on letter width: set in the browser, the bold condensed faces and
several scripts run that much wider than the letter measure, and a fit at
exactly the measure wrapped its last word out of the frame.

The layout operation of edit_book_page and revise_book_design takes the new
ids as a checked string and keeps the name of a locked group,
list_book_layouts lists them with archetype, tags and roles, and the design
prompt names them. A sibling extract script reads a page drawn in the
Studio back into the format, roles, tints and groups kept.
2026-10-24 00:36:00 +00:00
Maurice ea5447056b feat(studio): the seven cover archetypes a decorated book starts from 2026-10-24 00:35:48 +00:00
Maurice baec23cf73 fix(deps): body-parser past the compressed body advisory
body-parser before 1.20.9 and 2.x before 2.4.0 can be held up by trailing
bytes after a compressed request body (GHSA-27r3-jxmh-p828). Both majors are
held as floors in the root overrides, express 5 under Nest and the MCP SDK on
2.4.0 and express 4 on 1.20.9, with qs on 6.16.1 as 2.4.0 asks.
2026-10-23 22:41:20 +00:00
Maurice a202f2dc95 fix: the four reliability findings Sonar reports on dev
- The warmed fonts' key sorts by code units, so the same faces give the same
  key in every locale.
- The nearest weight of a snapshot face starts from the first face it has.
- The round hike search names its candidates with their place in the list
  explicitly instead of handing map a function of two arguments.
2026-10-23 22:40:27 +00:00
536 changed files with 26334 additions and 3913 deletions
+2 -2
View File
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
// ── Studio ──────────────────────────────────────────────────────────────────── // ── Studio ────────────────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.st-rail') const rail = (page: Page) => page.locator('.st-rail')
const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true }) const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true })
const sidePanel = (page: Page) => page.locator('.st-panel.st-side') const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
const inspector = (page: Page) => page.locator('.st-inspector') const inspector = (page: Page) => page.locator('.st-inspector')
const studioMenu = (page: Page) => page.locator('.st-menu').last() const studioMenu = (page: Page) => page.locator('.st-menu').last()
@@ -1431,7 +1431,7 @@ const SCRIPTS: Record<string, GuideScript> = {
}, },
{ {
prepare: async p => { prepare: async p => {
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click() await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible() await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
await settle(p) await settle(p)
}, },
+7 -7
View File
@@ -1,20 +1,20 @@
{ {
"src": { "src": {
"@typescript-eslint/no-non-null-assertion": 401, "@typescript-eslint/no-non-null-assertion": 385,
"@typescript-eslint/no-misused-promises": 850, "@typescript-eslint/no-misused-promises": 849,
"no-console": 88, "no-console": 88,
"complexity": 226, "complexity": 222,
"@typescript-eslint/no-explicit-any": 146, "@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130, "trek/no-swallowed-catch": 130,
"max-params": 33, "max-params": 32,
"trek/store-without-selector": 67, "trek/store-without-selector": 67,
"react-hooks/refs": 556, "react-hooks/refs": 556,
"react-hooks/set-state-in-effect": 194, "react-hooks/set-state-in-effect": 194,
"@typescript-eslint/no-floating-promises": 165, "@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116, "react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 125, "@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28, "react-hooks/immutability": 28,
"max-lines-per-function": 132, "max-lines-per-function": 130,
"react-hooks/static-components": 78, "react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 12, "react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7, "no-useless-assignment": 7,
@@ -35,7 +35,7 @@
}, },
"tests": { "tests": {
"@typescript-eslint/no-explicit-any": 490, "@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1932, "@typescript-eslint/no-non-null-assertion": 1931,
"@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51, "@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33, "@typescript-eslint/no-floating-promises": 33,
-5
View File
@@ -117,11 +117,6 @@ export const DYNAMIC_ALLOWED = [
template: 'settings.${mode}', template: 'settings.${mode}',
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist", because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
}, },
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
file,
template: 'journey.studio.${key}',
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
})),
{ {
file: 'mobile/screens/trip/sheets/MBookingFields.tsx', file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
template: 'reservations.${s}', template: 'reservations.${s}',
+1 -1
View File
@@ -38,8 +38,8 @@
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1, "src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1, "src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1, "src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/StudioSidebar.tsx": 1,
"src/components/Studio/TravelElements.tsx": 1, "src/components/Studio/TravelElements.tsx": 1,
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
"src/components/Todo/useTodoList.ts": 1, "src/components/Todo/useTodoList.ts": 1,
"src/components/shared/ConfirmDialog.tsx": 1, "src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.tsx": 1, "src/components/shared/CountryFlag.tsx": 1,
-2
View File
@@ -56,9 +56,7 @@
"components/Studio/StudioCanvas.tsx": 12, "components/Studio/StudioCanvas.tsx": 12,
"components/Studio/StudioElementsPanel.tsx": 1, "components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1, "components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioSavedLayouts.tsx": 1, "components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 4, "components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1, "components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3, "components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
+1
View File
@@ -1,6 +1,7 @@
{ {
"components/Studio/LineHandles.tsx": 1, "components/Studio/LineHandles.tsx": 1,
"components/Studio/StudioSelectionChrome.tsx": 2, "components/Studio/StudioSelectionChrome.tsx": 2,
"components/Studio/inspector/FocalPad.tsx": 1,
"mobile/components/trek3d/MascotClip.tsx": 1, "mobile/components/trek3d/MascotClip.tsx": 1,
"pages/tripPlanner/PlanMapStage.tsx": 4 "pages/tripPlanner/PlanMapStage.tsx": 4
} }
+1 -1
View File
@@ -26,5 +26,5 @@
"src/pages/atlas/useAtlas.ts": 1211, "src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270, "src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 2057 "src/styles/studio.css": 2009
} }
-1
View File
@@ -82,7 +82,6 @@
"src/components/Settings/NotificationsTab.test.tsx": 1, "src/components/Settings/NotificationsTab.test.tsx": 1,
"src/components/Settings/PasskeysSection.tsx": 1, "src/components/Settings/PasskeysSection.tsx": 1,
"src/components/Settings/PhotoProvidersSection.test.tsx": 2, "src/components/Settings/PhotoProvidersSection.test.tsx": 2,
"src/components/Studio/StudioInspector.tsx": 1,
"src/components/Todo/todoListModel.ts": 1, "src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2, "src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2, "src/components/Tours/planner/useTourPlanner.ts": 2,
+1 -2
View File
@@ -103,11 +103,10 @@
"components/Studio/PhotoView.tsx": 2, "components/Studio/PhotoView.tsx": 2,
"components/Studio/SpreadView.tsx": 1, "components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 8, "components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioShell.tsx": 2, "components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 5, "components/Studio/TravelElements.tsx": 5,
"components/Studio/printSheets.ts": 5, "components/Studio/printSheets.ts": 5,
"components/Studio/sidebar/contentDrops.ts": 2,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4, "components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
"components/SystemNotices/SystemNoticeBanner.tsx": 28, "components/SystemNotices/SystemNoticeBanner.tsx": 28,
"components/SystemNotices/SystemNoticeModal.tsx": 107, "components/SystemNotices/SystemNoticeModal.tsx": 107,
@@ -11,7 +11,7 @@ import {
Trash2, Trash2,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useState } from 'react'; import { type HTMLAttributes, type MouseEvent, useRef, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
@@ -79,6 +79,7 @@ export default function AssistantHeader({
const [memoryOpen, setMemoryOpen] = useState(false); const [memoryOpen, setMemoryOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const menu = useContextMenu(); const menu = useContextMenu();
const moreRef = useRef<HTMLButtonElement>(null);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const threads = thread.threads ?? []; const threads = thread.threads ?? [];
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1); const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
@@ -95,6 +96,14 @@ export default function AssistantHeader({
run(); run();
}; };
// Closed by Escape or a pick while it holds the focus, the menu hands it back to its button, so the keys
// go on reaching the window; a click or a scroll elsewhere leaves the focus where it went.
const closeMenu = () => {
const fromMenu = document.activeElement?.closest('[role="menu"]') != null;
menu.close();
if (fromMenu) moreRef.current?.focus();
};
const openMenu = (e: MouseEvent) => const openMenu = (e: MouseEvent) =>
menu.open( menu.open(
e, e,
@@ -187,10 +196,12 @@ export default function AssistantHeader({
</Tooltip> </Tooltip>
<Tooltip label={t('assistant.window.menu')}> <Tooltip label={t('assistant.window.menu')}>
<button <button
ref={moreRef}
type="button" type="button"
onClick={openMenu} onClick={openMenu}
aria-label={t('assistant.window.menu')} aria-label={t('assistant.window.menu')}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={menu.menu !== null}
className={ICON_BUTTON} className={ICON_BUTTON}
> >
<Ellipsis size={14} aria-hidden="true" /> <Ellipsis size={14} aria-hidden="true" />
@@ -218,6 +229,7 @@ export default function AssistantHeader({
icon={null} icon={null}
label={th.title} label={th.title}
active={th.id === thread.threadId} active={th.id === thread.threadId}
current={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))} onClick={() => pick(() => thread.openThread(th.id))}
trailing={ trailing={
<span className="flex-shrink-0 text-caption text-content-faint"> <span className="flex-shrink-0 text-caption text-content-faint">
@@ -234,7 +246,7 @@ export default function AssistantHeader({
)} )}
</PopoverListPanel> </PopoverListPanel>
<ContextMenu menu={menu.menu} onClose={menu.close} /> <ContextMenu menu={menu.menu} onClose={closeMenu} />
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />} {memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<ConfirmDialog <ConfirmDialog
isOpen={confirming} isOpen={confirming}
@@ -1,4 +1,5 @@
import { ChevronRight, Luggage } from 'lucide-react'; import { ChevronRight, Luggage } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState'; import EmptyState from '../shared/EmptyState';
@@ -8,11 +9,17 @@ import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicke
/** /**
* The user's trips to go on with in one trip's own chat, from the home chat's * The user's trips to go on with in one trip's own chat, from the home chat's
* head: that chat, its history and its changes belong to the trip. The * head: that chat, its history and its changes belong to the trip. The
* running and the next trip lead. * running and the next trip lead. The list opens before the trips are there,
* so the focus goes onto the first one once they come.
*/ */
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) { export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const trips = useAssistantTripPicker(); const trips = useAssistantTripPicker();
const first = useRef<HTMLButtonElement>(null);
useEffect(() => {
first.current?.focus({ preventScroll: true });
}, [trips]);
if (trips === null) { if (trips === null) {
return ( return (
@@ -32,9 +39,10 @@ export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerT
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0"> <ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
{trips.map((trip) => ( {trips.map((trip, i) => (
<li key={trip.id}> <li key={trip.id}>
<button <button
ref={i === 0 ? first : undefined}
type="button" type="button"
onClick={() => onPick(trip)} onClick={() => onPick(trip)}
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover" className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
@@ -1,5 +1,5 @@
/** /**
* FE-AI-WIN-001 to FE-AI-WIN-014 * FE-AI-WIN-001 to FE-AI-WIN-016
* *
* The desktop window around the chat: when it shows at all, the button it * The desktop window around the chat: when it shows at all, the button it
* opens from, the home chat outside a trip with its way into one trip's chat * opens from, the home chat outside a trip with its way into one trip's chat
@@ -238,6 +238,34 @@ describe('AssistantWindow', () => {
expect(useAssistantStore.getState().window.open).toBe(true); expect(useAssistantStore.getState().window.open).toBe(true);
}); });
it("FE-AI-WIN-015: the header's menu says whether it is open, and Escape hands the focus back to its button", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
const more = screen.getByRole('button', { name: 'More options' });
expect(more).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(more);
expect(more).toHaveAttribute('aria-expanded', 'true');
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
expect(more).toHaveFocus();
expect(more).toHaveAttribute('aria-expanded', 'false');
// From the button the next Escape is the window's again.
fireEvent.keyDown(more, { key: 'Escape' });
expect(useAssistantStore.getState().window.open).toBe(false);
});
it("FE-AI-WIN-016: the home chat's trip list takes the focus onto the first trip once the trips are there", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
const button = screen.getByRole('button', { name: 'Chat about one trip' });
fireEvent.click(button);
// The list's own row, not the chat's "go on in Lisbon" beside it.
const lisbon = await screen.findByRole('button', { name: /^Lisbon/ });
await waitFor(() => expect(lisbon).toHaveFocus());
fireEvent.keyDown(lisbon, { key: 'Escape' });
expect(button).toHaveFocus();
});
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => { it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
seed({ open: true, minimized: false }); seed({ open: true, minimized: false });
const onFocusDay = vi.fn(); const onFocusDay = vi.fn();
@@ -1,5 +1,5 @@
/** /**
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-016 * FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-017
* *
* The pure side of the book's page card: a page named as the Studio names * The pure side of the book's page card: a page named as the Studio names
* it, a layout and a finding by their words, the findings worth a look, the * it, a layout and a finding by their words, the findings worth a look, the
@@ -8,7 +8,7 @@
* the card carries it and the book is still at the trial's version), and * the card carries it and the book is still at the trial's version), and
* where an applied change opens the Studio. * where an applied change opens the Studio.
*/ */
import { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared'; import { BOOK_FINDING_CODES, blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
@@ -67,6 +67,9 @@ describe('bookCardModel', () => {
it('FE-AI-BOOKPAGE-011: a layout is named by its template, its grid, its own name or as laid out again', () => { it('FE-AI-BOOKPAGE-011: a layout is named by its template, its grid, its own name or as laid out again', () => {
expect(layoutLabel({ kind: 'template', id: 'two-up', name: null }, t)).toBe('journey.studio.tpl.twoUp'); expect(layoutLabel({ kind: 'template', id: 'two-up', name: null }, t)).toBe('journey.studio.tpl.twoUp');
expect(layoutLabel({ kind: 'template', id: 'cover-full', name: null }, t)).toBe('journey.studio.tpl.coverFull'); expect(layoutLabel({ kind: 'template', id: 'cover-full', name: null }, t)).toBe('journey.studio.tpl.coverFull');
expect(layoutLabel({ kind: 'template', id: 'postcard-houses', name: null }, t)).toBe(
'studio.templates.name.postcardHouses'
);
expect(layoutLabel({ kind: 'grid', id: 'two-across', name: null }, t)).toBe('assistant.card.book.grid {"count":2}'); expect(layoutLabel({ kind: 'grid', id: 'two-across', name: null }, t)).toBe('assistant.card.book.grid {"count":2}');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: 'Mine' }, t)).toBe('Mine'); expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: 'Mine' }, t)).toBe('Mine');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1'); expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1');
@@ -92,6 +95,15 @@ describe('bookCardModel', () => {
expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi'); expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi');
}); });
it('FE-AI-BOOKPAGE-017: every finding the checks answer with has its words, the art checks among them', () => {
for (const code of BOOK_FINDING_CODES) {
expect(findingLabel({ code, severity: 'info' }, t)).toBe(`assistant.card.book.finding.${code}`);
}
for (const code of ['flat_cover', 'weak_hierarchy', 'off_theme', 'energy_mismatch'] as const) {
expect(BOOK_FINDING_CODES).toContain(code);
}
});
it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => { it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => {
const doc = book(); const doc = book();
const [edit] = pageViewOf(card(), doc, 'pending'); const [edit] = pageViewOf(card(), doc, 'pending');
@@ -10,6 +10,7 @@ import {
type BookSpread, type BookSpread,
findBookGrid, findBookGrid,
findBookTemplate, findBookTemplate,
findDecoratedTemplate,
setBookCover, setBookCover,
} from '@trek/shared'; } from '@trek/shared';
@@ -62,6 +63,10 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' }, edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' }, curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' }, effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
flat_cover: { labelKey: 'assistant.card.book.finding.flat_cover' },
weak_hierarchy: { labelKey: 'assistant.card.book.finding.weak_hierarchy' },
off_theme: { labelKey: 'assistant.card.book.finding.off_theme' },
energy_mismatch: { labelKey: 'assistant.card.book.finding.energy_mismatch' },
}; };
/** A page as the Studio names it: the cover, the first page, double page 3. */ /** A page as the Studio names it: the cover, the first page, double page 3. */
@@ -87,7 +92,7 @@ export function layoutLabel(layout: BookCardLayout | null, t: Translate): string
const grid = findBookGrid(layout.id); const grid = findBookGrid(layout.id);
return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null; return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null;
} }
const template = findBookTemplate(layout.id); const template = findBookTemplate(layout.id) ?? findDecoratedTemplate(layout.id);
return template ? t(template.labelKey) : null; return template ? t(template.labelKey) : null;
} }
@@ -37,7 +37,7 @@ export const BOOK_LOOK = {
// The server's own read: it waits for the Studio to draw the page, and says it looks at it. // The server's own read: it waits for the Studio to draw the page, and says it looks at it.
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' }, preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' }, list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
list_book_assets: { stepKey: 'assistant.step.bookStickers' }, list_book_assets: { stepKey: 'assistant.step.bookAssets' },
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' }, suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
}, },
entities: { entities: {
@@ -1,4 +1,4 @@
// FE-PACKPOP-001 to FE-PACKPOP-008 // FE-PACKPOP-001 to FE-PACKPOP-009
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { useRef } from 'react' import { useRef } from 'react'
import { render, screen, fireEvent } from '../../../tests/helpers/render' import { render, screen, fireEvent } from '../../../tests/helpers/render'
@@ -56,6 +56,15 @@ describe('PopoverItem', () => {
expect(danger.style.background).toBe('none') expect(danger.style.background).toBe('none')
}) })
it('FE-PACKPOP-009: the entry open now is said to be the current one, not only tinted', () => {
render(<>
<PopoverItem icon={null} label="Temples" active current onClick={() => {}} />
<PopoverItem icon={null} label="Food" onClick={() => {}} />
</>)
expect(screen.getByRole('button', { name: 'Temples' })).toHaveAttribute('aria-current', 'true')
expect(screen.getByRole('button', { name: 'Food' })).not.toHaveAttribute('aria-current')
})
it('FE-PACKPOP-004: the composer button fades while there is nothing to add', () => { it('FE-PACKPOP-004: the composer button fades while there is nothing to add', () => {
expect(composerConfirm(false)).toMatchObject({ opacity: 0.35, cursor: 'default' }) expect(composerConfirm(false)).toMatchObject({ opacity: 0.35, cursor: 'default' })
expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' }) expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' })
@@ -7,6 +7,8 @@ interface PopoverItemProps {
label: string label: string
onClick: () => void onClick: () => void
active?: boolean active?: boolean
/** The entry is the one open now (a chat in a history list), said so to a screen reader too. */
current?: boolean
danger?: boolean danger?: boolean
/** A secondary line, such as Add bag, in the faint text colour. */ /** A secondary line, such as Add bag, in the faint text colour. */
muted?: boolean muted?: boolean
@@ -14,11 +16,12 @@ interface PopoverItemProps {
trailing?: ReactNode trailing?: ReactNode
} }
export function PopoverItem({ icon, label, onClick, active = false, danger = false, muted = false, trailing }: PopoverItemProps) { export function PopoverItem({ icon, label, onClick, active = false, current = false, danger = false, muted = false, trailing }: PopoverItemProps) {
const tone = danger ? '#ef4444' : muted ? 'var(--text-faint)' : 'var(--text-primary)' const tone = danger ? '#ef4444' : muted ? 'var(--text-faint)' : 'var(--text-primary)'
return ( return (
<button type="button" <button type="button"
onClick={onClick} onClick={onClick}
aria-current={current || undefined}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 9, width: '100%', display: 'flex', alignItems: 'center', gap: 9, width: '100%',
padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer', padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer',
@@ -9,7 +9,12 @@ import {
AlignStartHorizontal, AlignStartHorizontal,
AlignStartVertical, AlignStartVertical,
AlignVerticalDistributeCenter, AlignVerticalDistributeCenter,
ArrowDown,
ArrowUp,
ChevronsDown,
ChevronsUp,
Group, Group,
Layers,
Paintbrush, Paintbrush,
Pipette, Pipette,
Ungroup, Ungroup,
@@ -18,17 +23,32 @@ import {
import { useState } from 'react'; import { useState } from 'react';
import { useStudioStore } from '../../store/studioStore'; import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-panels.css';
import { Tooltip } from '../shared/Tooltip'; import { Tooltip } from '../shared/Tooltip';
import { Choice, Section } from './StudioControls'; import { Choice, Line, Section } from './StudioControls';
import { useStudioShortcut } from './studioShortcut'; import { useStudioShortcut } from './studioShortcut';
/** /**
* Align, distribute, group and the style clipboard, for whatever is selected. * Arrange: where the selection sits among the other things on the page.
* *
* The align bar lines a multi-selection up within its own box by default, and * Lining it up and spacing it out, what it lines up against, what lies in
* front of what, grouping, and the style clipboard, in one group at the foot
* of the inspector. These were two groups, Align and Arrange, one under the
* other and both about the selection's place among its neighbours, so the
* stacking buttons were a fold away from the alignment they are used with.
*
* Every line puts its row under its label, across the whole width of the
* panel: the six edges and the two spacings are eight buttons, more than fit
* beside a label. Each row is two sets, the edges and the spacings, the stack
* and the grouping, and a set that no longer fits moves to the next line
* whole, so a narrow panel shows two even rows rather than a ragged one.
*
* The align row lines a multi-selection up within its own box by default, and
* a single element (or one whole group) against its page, since there is * a single element (or one whole group) against its page, since there is
* nothing else for it to line up with. Groups move as one box throughout * nothing else for it to line up with. Groups move as one box throughout
* (shared/src/book/book-arrange.ts). Every press is one undo step. * (shared/src/book/book-arrange.ts). The Layers button opens the rail's
* Layers panel, the whole stack of the page beside the selection. Every press
* is one undo step.
*/ */
const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [ const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
@@ -40,6 +60,13 @@ const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
{ edge: 'bottom', icon: AlignEndHorizontal, labelKey: 'journey.studio.layers.alignBottom' }, { edge: 'bottom', icon: AlignEndHorizontal, labelKey: 'journey.studio.layers.alignBottom' },
]; ];
const STACK: { to: 'front' | 'up' | 'down' | 'back'; icon: LucideIcon; labelKey: string }[] = [
{ to: 'front', icon: ChevronsUp, labelKey: 'journey.studio.toFront' },
{ to: 'up', icon: ArrowUp, labelKey: 'journey.studio.forward' },
{ to: 'down', icon: ArrowDown, labelKey: 'journey.studio.backward' },
{ to: 'back', icon: ChevronsDown, labelKey: 'journey.studio.toBack' },
];
/** How many things move as one in a selection: a group counts once. */ /** How many things move as one in a selection: a group counts once. */
function unitCount(spread: BookSpread, ids: string[]): number { function unitCount(spread: BookSpread, ids: string[]): number {
return distributableCount( return distributableCount(
@@ -70,12 +97,22 @@ function IconButton({
); );
} }
export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number; t: (k: string) => string }) { export function StudioArrangeSection({
spreadIndex,
t,
onShowLayers,
}: {
spreadIndex: number;
t: (k: string) => string;
/** Opens the Layers panel in the rail; without it the button is left out. */
onShowLayers?: () => void;
}) {
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]); const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
const selection = useStudioStore((s) => s.selection); const selection = useStudioStore((s) => s.selection);
const hasStyle = useStudioStore((s) => s.styleClip !== null); const hasStyle = useStudioStore((s) => s.styleClip !== null);
const align = useStudioStore((s) => s.align); const align = useStudioStore((s) => s.align);
const distribute = useStudioStore((s) => s.distribute); const distribute = useStudioStore((s) => s.distribute);
const raise = useStudioStore((s) => s.raise);
const group = useStudioStore((s) => s.group); const group = useStudioStore((s) => s.group);
const ungroup = useStudioStore((s) => s.ungroup); const ungroup = useStudioStore((s) => s.ungroup);
const copyStyle = useStudioStore((s) => s.copyStyle); const copyStyle = useStudioStore((s) => s.copyStyle);
@@ -95,76 +132,102 @@ export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number;
return ( return (
// Open for a multi-selection, where lining up is what comes next; folded for one element. // Open for a multi-selection, where lining up is what comes next; folded for one element.
<Section key={units > 1 ? 'many' : 'one'} label={t('journey.studio.layers.align')} defaultOpen={units > 1}> <Section key={units > 1 ? 'many' : 'one'} label={t('journey.studio.arrange')} defaultOpen={units > 1}>
<div className="st-row"> <div className="st-arrange">
{ALIGN.map((a) => ( <Line label={t('journey.studio.layers.align')} wide>
<IconButton <div className="st-row st-arrange-row">
key={a.edge} <span className="st-arrange-set">
label={t(a.labelKey)} {ALIGN.map((a) => (
icon={a.icon} <IconButton
disabled={!movable.length} key={a.edge}
onClick={() => align(spreadIndex, a.edge, to)} label={t(a.labelKey)}
icon={a.icon}
disabled={!movable.length}
onClick={() => align(spreadIndex, a.edge, to)}
/>
))}
</span>
<span className="st-arrange-set">
<IconButton
label={t('journey.studio.layers.distributeH')}
icon={AlignHorizontalDistributeCenter}
disabled={units < 3}
onClick={() => distribute(spreadIndex, 'horizontal')}
/>
<IconButton
label={t('journey.studio.layers.distributeV')}
icon={AlignVerticalDistributeCenter}
disabled={units < 3}
onClick={() => distribute(spreadIndex, 'vertical')}
/>
</span>
</div>
</Line>
<Line label={t('journey.studio.layers.alignTo')} wide>
<Choice<BookAlignTo>
value={to}
ariaLabel={t('journey.studio.layers.alignTo')}
options={[
{ value: 'selection' as const, label: t('journey.studio.layers.toSelection'), disabled: units < 2 },
{ value: 'page' as const, label: t('journey.studio.layers.toPage') },
{ value: 'safe' as const, label: t('journey.studio.layers.toSafe') },
]}
onPick={setChosenTo}
/> />
))} </Line>
<span className="st-row-sep" aria-hidden /> <Line label={t('journey.studio.order')} wide>
<IconButton <div className="st-row st-arrange-row">
label={t('journey.studio.layers.distributeH')} <span className="st-arrange-set">
icon={AlignHorizontalDistributeCenter} {STACK.map((s) => (
disabled={units < 3} <IconButton
onClick={() => distribute(spreadIndex, 'horizontal')} key={s.to}
/> label={t(s.labelKey)}
<IconButton icon={s.icon}
label={t('journey.studio.layers.distributeV')} onClick={() => raise(spreadIndex, selection, s.to)}
icon={AlignVerticalDistributeCenter} />
disabled={units < 3} ))}
onClick={() => distribute(spreadIndex, 'vertical')} </span>
/> <span className="st-arrange-set">
</div> {(canGroup || !grouped) && (
<div className="st-arrange-to"> <IconButton
<span className="st-line-label">{t('journey.studio.layers.alignTo')}</span> label={t('journey.studio.layers.groupAction')}
<Choice<BookAlignTo> icon={Group}
value={to} disabled={!canGroup}
ariaLabel={t('journey.studio.layers.alignTo')} shortcut={keys('G')}
options={[ onClick={() => group(spreadIndex)}
{ value: 'selection' as const, label: t('journey.studio.layers.toSelection'), disabled: units < 2 }, />
{ value: 'page' as const, label: t('journey.studio.layers.toPage') }, )}
{ value: 'safe' as const, label: t('journey.studio.layers.toSafe') }, {grouped && (
]} <IconButton
onPick={setChosenTo} label={t('journey.studio.layers.ungroup')}
/> icon={Ungroup}
</div> shortcut={keys('⇧', 'G')}
<div className="st-row st-arrange-acts"> onClick={() => ungroup(spreadIndex)}
{(canGroup || !grouped) && ( />
<IconButton )}
label={t('journey.studio.layers.groupAction')} {onShowLayers && (
icon={Group} <IconButton label={t('journey.studio.showLayers')} icon={Layers} onClick={onShowLayers} />
disabled={!canGroup} )}
shortcut={keys('G')} </span>
onClick={() => group(spreadIndex)} </div>
/> </Line>
)} <Line label={t('journey.studio.style')} wide>
{grouped && ( <div className="st-row">
<IconButton <IconButton
label={t('journey.studio.layers.ungroup')} label={t('journey.studio.layers.copyStyle')}
icon={Ungroup} icon={Pipette}
shortcut={keys('⇧', 'G')} shortcut={keys('alt', 'C')}
onClick={() => ungroup(spreadIndex)} onClick={() => copyStyle(spreadIndex)}
/> />
)} <IconButton
<span className="st-row-sep" aria-hidden /> label={t('journey.studio.layers.pasteStyle')}
<IconButton icon={Paintbrush}
label={t('journey.studio.layers.copyStyle')} disabled={!hasStyle || !movable.length}
icon={Pipette} shortcut={keys('alt', 'V')}
shortcut={keys('alt', 'C')} onClick={() => pasteStyle(spreadIndex)}
onClick={() => copyStyle(spreadIndex)} />
/> </div>
<IconButton </Line>
label={t('journey.studio.layers.pasteStyle')}
icon={Paintbrush}
disabled={!hasStyle || !movable.length}
shortcut={keys('alt', 'V')}
onClick={() => pasteStyle(spreadIndex)}
/>
</div> </div>
{asGroup && <p className="st-hint st-arrange-hint">{t('journey.studio.layers.groupHint')}</p>} {asGroup && <p className="st-hint st-arrange-hint">{t('journey.studio.layers.groupHint')}</p>}
</Section> </Section>
@@ -1,4 +1,4 @@
import { bookElementId, spreadPaint, withSpreadPaint, type BookPageSetup } from '@trek/shared'; import { bookElementId, spreadPaint, withSpreadPaintByHand, type BookPageSetup } from '@trek/shared';
import { Check, CopyCheck, ImageOff } from 'lucide-react'; import { Check, CopyCheck, ImageOff } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useStudioStore } from '../../store/studioStore'; import { useStudioStore } from '../../store/studioStore';
@@ -37,9 +37,10 @@ export function StudioBackgroundSection({
}) { }) {
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]); const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
const commit = useStudioStore((s) => s.commit); const commit = useStudioStore((s) => s.commit);
// A colour picked here is the person's, so the page stops following the theme's paper (as the assistant's does).
const channel = usePaintChannel((paint) => (doc) => ({ const channel = usePaintChannel((paint) => (doc) => ({
...doc, ...doc,
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaint(sp, paint) : sp)), spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaintByHand(sp, paint) : sp)),
})); }));
// "Applied" stands in the button for a moment, so a press that changed pages out of sight is seen to have worked. // "Applied" stands in the button for a moment, so a press that changed pages out of sight is seen to have worked.
@@ -1,9 +1,11 @@
import type { BookDocument, BookElement } from '@trek/shared'; import type { BookDocument, BookElement } from '@trek/shared';
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared'; import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render } from '../../../tests/helpers/render'; import { fireEvent, render } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore'; import { useStudioStore } from '../../store/studioStore';
import { AngleDial } from './paint/AngleDial';
import { GradientStopBar } from './paint/GradientStopBar';
import { StudioCanvas } from './StudioCanvas'; import { StudioCanvas } from './StudioCanvas';
/** /**
@@ -267,6 +269,55 @@ describe('the keyboard', () => {
expect(again.defaultPrevented).toBe(false); expect(again.defaultPrevented).toBe(false);
}); });
it('leaves a key to the control that takes it: a gradient stop, the angle dial', () => {
load([shape('a', 10, 10)]);
store().select(['a']);
draw();
const fade = (n: number) => ({
type: 'linear' as const,
angle: 90,
stops: Array.from({ length: n }, (_, i) => ({ at: i / (n - 1), color: '#112233' })),
});
const onChange = vi.fn();
const labels = { bar: 'bar', stop: (pos: number) => `stop ${pos}` };
const three = render(
<GradientStopBar paint={fade(3)} selected={1} onSelect={() => {}} onChange={onChange} labels={labels} />
);
fireEvent.keyDown(must(three.container.querySelectorAll('.st-stop')[1]), { key: 'Delete' });
expect(onChange.mock.calls[0]?.[0].stops).toHaveLength(2);
three.unmount();
// The last two stops cannot go, and the key still does not reach the shape.
const two = render(
<GradientStopBar paint={fade(2)} selected={0} onSelect={() => {}} onChange={onChange} labels={labels} />
);
fireEvent.keyDown(must(two.container.querySelector('.st-stop')), { key: 'Backspace' });
two.unmount();
expect(elements().map((e) => e.id)).toEqual(['a']);
const dial = render(<AngleDial value={90} onChange={() => {}} label="Angle" />);
fireEvent.keyDown(must(dial.container.querySelector('.st-dial')), { key: 'ArrowRight', shiftKey: true });
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
});
it("leaves the keys pressed in the assistant's window to it", () => {
load([shape('a', 10, 10)]);
store().select(['a']);
store().copy(0, ['a']);
draw();
// As the Studio's assistant window does it: every key pressed in it stops there (StudioAssistantPanel.tsx).
const chat = render(
<aside className="st-assistant" onKeyDown={(e) => e.stopPropagation()}>
<button type="button">Apply</button>
</aside>
);
const button = must(chat.container.querySelector('button'));
fireEvent.keyDown(button, { key: 'Delete' });
fireEvent.keyDown(button, { key: 'ArrowDown' });
fireEvent.keyDown(button, { key: 'v', ctrlKey: true });
expect(elements().map((e) => e.id)).toEqual(['a']);
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
});
it('leaves the keys to a field being typed in', () => { it('leaves the keys to a field being typed in', () => {
load([shape('a', 10, 10)]); load([shape('a', 10, 10)]);
store().select(['a']); store().select(['a']);
@@ -235,7 +235,6 @@ export function StudioCanvas({
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
mask: null, frameStyle: 'none', mask: null, frameStyle: 'none',
} as BookElement) } as BookElement)
select([id])
}} }}
> >
{/* {/*
@@ -13,9 +13,10 @@ import type { PhotoFilter } from './photoFilter'
* heading over the grid rather than as one control among several, and a long * heading over the grid rather than as one control among several, and a long
* entry name has somewhere to go. * entry name has somewhere to go.
* *
* The popover is the top bar's format menu, hung from the left edge instead of * The popover is the top bar's format menu, hung from the start edge instead
* the right: the side panel is 236px wide and a menu anchored right would open * of the end: the side panel is 236px wide and a menu anchored at the end would
* into the rail. * open into the rail. A long entry name takes two lines in the chip, and the
* menu shows every name whole.
*/ */
export function StudioContentFilter({ export function StudioContentFilter({
source, filter, setFilter, admitted, pageEntryId, t, locale, source, filter, setFilter, admitted, pageEntryId, t, locale,
@@ -79,7 +80,7 @@ export function StudioContentFilter({
aria-expanded={open} aria-expanded={open}
title={t('journey.studio.filterPhotos')} title={t('journey.studio.filterPhotos')}
> >
<span className="st-filter-name">{label}</span> <span className="st-filter-name" title={label}>{label}</span>
<em>{admitted}</em> <em>{admitted}</em>
<ChevronDown size={12} style={{ opacity: .5 }} /> <ChevronDown size={12} style={{ opacity: .5 }} />
</button> </button>
+31 -12
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react' import { useId, useState, type ReactNode } from 'react'
import { Check, ChevronDown } from 'lucide-react' import { Check, ChevronDown } from 'lucide-react'
import ToggleSwitch from '../Settings/ToggleSwitch' import ToggleSwitch from '../Settings/ToggleSwitch'
@@ -31,10 +31,15 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
/** /**
* One group of properties. * One group of properties.
* *
* `defaultOpen={false}` is for the groups you set once and rarely return to — * `defaultOpen={false}` is for the groups you set once and rarely return to:
* the frame's numbers, mostly. The state is per mounted section rather than * the frame's numbers, the effects, the advanced settings of a kind. The
* remembered: coming back to an element and finding its panel folded up * inspector opens such a group anyway when the element already uses it, so a
* differently from last time is worse than opening one group again. * photo with a duotone shows its duotone and a plain one keeps it out of the
* way (inspector/inspectorModel.ts says which groups are in use). The state
* is per mounted section rather than remembered: the inspector mounts its
* groups afresh for every selection, and coming back to an element and finding
* its panel folded up differently from last time is worse than opening one
* group again.
*/ */
export function Section({ label, children, defaultOpen = true, hint, badge }: { export function Section({ label, children, defaultOpen = true, hint, badge }: {
label: string label: string
@@ -51,15 +56,19 @@ export function Section({ label, children, defaultOpen = true, hint, badge }: {
badge?: string badge?: string
}) { }) {
const [open, setOpen] = useState(defaultOpen) const [open, setOpen] = useState(defaultOpen)
const body = useId()
return ( return (
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}> <div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}> <h3 className="st-section-title">
<span className="st-section-label">{label}</span> <button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}
{badge && <span className="st-section-badge">{badge}</span>} aria-controls={open ? body : undefined}>
<ChevronDown size={13} className="st-section-caret" /> <span className="st-section-label">{label}</span>
</button> {badge && <span className="st-section-badge">{badge}</span>}
<ChevronDown size={13} className="st-section-caret" aria-hidden />
</button>
</h3>
{open && ( {open && (
<div className="st-section-body"> <div className="st-section-body" id={body}>
{hint && <p className="st-hint st-section-hint">{hint}</p>} {hint && <p className="st-hint st-section-hint">{hint}</p>}
{children} {children}
</div> </div>
@@ -68,7 +77,17 @@ export function Section({ label, children, defaultOpen = true, hint, badge }: {
) )
} }
/** A labelled line. The label sits left, the control fills what is left. */ /**
* A labelled line: the label first, the control after it.
*
* The two share the line while they fit and the control moves under the label
* when they do not, so a long label ("Πεζά και κεφαλαία", "Átlátszatlanság")
* pushes its control down a line instead of being cut or squeezing the control
* to nothing. There is no column of a fixed width for the label to fit into;
* short labels sit at a common minimum, so the controls of a group still start
* at one edge. `wide` puts the control under the label always, for a control
* that needs the whole width, like a row of swatches.
*/
export function Line({ label, children, wide = false }: { export function Line({ label, children, wide = false }: {
label: string label: string
children: ReactNode children: ReactNode
@@ -7,9 +7,10 @@ import {
bookShapeTakesEdge, bookShapeTakesEdge,
} from '@trek/shared'; } from '@trek/shared';
import { NumField, Section } from './StudioControls'; import { edgeInUse } from './inspector/inspectorModel';
import { DEFAULT_EDGE_SIZE } from './shapeOutline'; import { DEFAULT_EDGE_SIZE } from './shapeOutline';
import { edgedPath } from './shapes-edges'; import { edgedPath } from './shapes-edges';
import { NumField, Section } from './StudioControls';
/** /**
* The edge of a shape or of a photo's cut: a row of tiles, each the edge drawn * The edge of a shape or of a photo's cut: a row of tiles, each the edge drawn
@@ -50,12 +51,8 @@ export function EdgeSection({
// A heart or a star keeps its drawn line, so there is nothing to offer unless an edge is already set on one. // A heart or a star keeps its drawn line, so there is nothing to offer unless an edge is already set on one.
if (!takes && current === 'straight') return null; if (!takes && current === 'straight') return null;
return ( return (
<Section label={t('journey.studio.edgeStyle')} defaultOpen={current !== 'straight'}> <Section label={t('journey.studio.edgeStyle')} defaultOpen={edgeInUse(edge)}>
{!takes && ( {!takes && <p className="st-hint st-hint-before">{t('journey.studio.edgeUnavailable')}</p>}
<p className="st-hint" style={{ paddingBottom: 8 }}>
{t('journey.studio.edgeUnavailable')}
</p>
)}
<div className="st-mini-shapes"> <div className="st-mini-shapes">
{BOOK_EDGES.map((e) => ( {BOOK_EDGES.map((e) => (
<button <button
@@ -72,7 +69,7 @@ export function EdgeSection({
))} ))}
</div> </div>
{takes && current !== 'straight' && ( {takes && current !== 'straight' && (
<div style={{ marginTop: 10 }}> <div className="st-section-gap">
<NumField <NumField
label={t('journey.studio.edgeSize')} label={t('journey.studio.edgeSize')}
value={edgeSize ?? DEFAULT_EDGE_SIZE} value={edgeSize ?? DEFAULT_EDGE_SIZE}
@@ -0,0 +1,147 @@
import type { BookDocument } from '@trek/shared';
import { BOOK_GRIDS, MAX_SPREAD_ELEMENTS, bookPageSetupSchema, normalizeBookDocument } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { StudioElementsPanel } from './StudioElementsPanel';
import { searchIcons } from './iconLibrary';
import { DIVIDER_PRESETS, LINE_PRESETS } from './linePresets';
import { matchStickers } from './stickerLibrary';
/**
* FE-STUDIO-ELEMENTS-001 to FE-STUDIO-ELEMENTS-007: the Elements panel's six
* tabs, each opening its own shelves, and each placing what it shows on the
* open page, as long as the page has room for it.
*/
const t = (k: string) => k;
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
const source = { entries: [], photos: [] };
const book = (): BookDocument =>
normalizeBookDocument({
version: 1,
title: 'Porto',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads: [{ id: 'sp1', role: 'inner', entryId: null, background: null, elements: [], parked: [] }],
});
const elements = () => useStudioStore.getState().doc?.spreads[0]?.elements ?? [];
const last = () => elements()[elements().length - 1];
const open = (name: string) => fireEvent.click(screen.getByRole('tab', { name }));
beforeEach(() => {
window.localStorage.clear();
useStudioStore.getState().load(book());
});
describe('the Elements panel', () => {
it('FE-STUDIO-ELEMENTS-001: opens on Text, with the quotation mark among the plain styles', () => {
render(<StudioElementsPanel page={page} t={t} source={source} />);
const panel = screen.getByRole('tabpanel');
expect(panel.getAttribute('aria-labelledby')).toBe(screen.getByRole('tab', { name: 'journey.studio.text' }).id);
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.quoteMark' }));
expect(last()).toMatchObject({ kind: 'text', text: '“', size: 46 });
});
it('FE-STUDIO-ELEMENTS-002: puts the outline-only rectangle first among the basic shapes', () => {
render(<StudioElementsPanel page={page} t={t} source={source} />);
open('journey.studio.shapes');
const outline = screen.getByRole('button', { name: 'journey.studio.shapeKind.outline' });
expect(outline.closest('.st-shape-grid')?.firstElementChild).toBe(outline);
fireEvent.click(outline);
expect(last()).toMatchObject({ kind: 'shape', shape: 'rect', fill: null, stroke: '#141414' });
});
it('FE-STUDIO-ELEMENTS-003: keeps the plain rule with the drawn lines and the dividers', () => {
const { container } = render(<StudioElementsPanel page={page} t={t} source={source} />);
open('journey.studio.lines');
expect(container.querySelectorAll('.st-shape-btn')).toHaveLength(LINE_PRESETS.length + DIVIDER_PRESETS.length + 1);
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.shapeKind.line' }));
expect(last()).toMatchObject({ kind: 'shape', shape: 'rect', frame: { h: 0.5 } });
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.linePreset.route' }));
expect(last()).toMatchObject({ kind: 'line' });
});
it('FE-STUDIO-ELEMENTS-004: offers frames in their styles and shapes, and the grids, in one tab', () => {
render(<StudioElementsPanel page={page} t={t} source={source} />);
open('journey.studio.frames');
expect(screen.getByText('journey.studio.frameStyles')).toBeTruthy();
expect(screen.getByText('journey.studio.frameShapes')).toBeTruthy();
expect(screen.getByText('journey.studio.grids')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.polaroidFrame' }));
expect(last()).toMatchObject({ kind: 'photo', photoId: null, frameStyle: 'polaroid' });
const grid = BOOK_GRIDS[0];
if (!grid) throw new Error('no grids');
fireEvent.click(screen.getByRole('button', { name: grid.id }));
expect(useStudioStore.getState().selection).toHaveLength(grid.cells.length);
});
it('FE-STUDIO-ELEMENTS-007: places nothing on a page that holds all it can, a frame or a whole grid', () => {
const grid = BOOK_GRIDS.find((g) => g.cells.length > 1);
if (!grid) throw new Error('no grid of several frames');
const crowded = (n: number) =>
normalizeBookDocument({
...book(),
spreads: [
{
id: 'sp1',
role: 'inner',
entryId: null,
background: null,
parked: [],
elements: Array.from({ length: n }, (_, i) => ({
id: `s${i}`,
kind: 'shape',
frame: { x: 0, y: 0, w: 5, h: 5 },
})),
},
],
});
useStudioStore.getState().load(crowded(MAX_SPREAD_ELEMENTS));
render(<StudioElementsPanel page={page} t={t} source={source} />);
open('journey.studio.frames');
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.polaroidFrame' }));
expect(elements()).toHaveLength(MAX_SPREAD_ELEMENTS);
expect(useStudioStore.getState().selection).toEqual([]);
useStudioStore.getState().load(crowded(MAX_SPREAD_ELEMENTS - 1));
fireEvent.click(screen.getByRole('button', { name: grid.id }));
expect(elements()).toHaveLength(MAX_SPREAD_ELEMENTS - 1);
expect(useStudioStore.getState().canUndo()).toBe(false);
});
it('FE-STUDIO-ELEMENTS-005: searches the icons and the stickers above their grids', () => {
render(<StudioElementsPanel page={page} t={t} source={source} />);
open('journey.studio.icons');
fireEvent.change(screen.getByRole('textbox', { name: 'journey.studio.searchIcons' }), {
target: { value: 'compass' },
});
fireEvent.click(screen.getByRole('button', { name: 'Compass' }));
expect(last()).toMatchObject({ kind: 'icon', name: 'Compass' });
open('journey.studio.stickers');
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.sticker.sun' }));
expect(last()).toMatchObject({ kind: 'sticker', stickerId: 'sun' });
});
it('FE-STUDIO-ELEMENTS-006: says in the search field how many stickers and icons the words match', () => {
render(<StudioElementsPanel page={page} t={t} source={source} />);
open('journey.studio.stickers');
expect(screen.queryByRole('status')).toBeNull();
fireEvent.change(screen.getByRole('textbox', { name: 'journey.studio.searchStickers' }), {
target: { value: 'sun' },
});
expect(screen.getByRole('status').textContent).toBe(String(matchStickers('sun', null, t).length));
open('journey.studio.icons');
fireEvent.change(screen.getByRole('textbox', { name: 'journey.studio.searchIcons' }), {
target: { value: 'compass' },
});
expect(screen.getByRole('status').textContent).toBe(String(searchIcons('compass').length));
// Spaces are not a search, so they leave the count out.
fireEvent.change(screen.getByRole('textbox', { name: 'journey.studio.searchIcons' }), {
target: { value: ' ' },
});
expect(screen.queryByRole('status')).toBeNull();
});
});
@@ -1,33 +1,47 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useId, useState } from 'react'
import { Minus, Quote, Search, Square, X } from 'lucide-react' import { Frame, Minus, Quote, Shapes, Spline, Sparkles, Square, Sticker, Type, type LucideIcon } from 'lucide-react'
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared' import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore' import { useStudioStore } from '../../store/studioStore'
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS } from './shapes' import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS } from './shapes'
import { BOOK_GRIDS as GRIDS, bookElementId, bookShapeSize, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared' import {
BOOK_GRIDS as GRIDS, MAX_SPREAD_ELEMENTS, bookElementId, bookShapeSize, bookGridElements as gridElements,
defaultBookGridBox as defaultGridBox,
} from '@trek/shared'
import { shapeTile } from './shapeOutline' import { shapeTile } from './shapeOutline'
import { StudioLineShelf } from './StudioLineShelf' import { StudioLineShelf } from './StudioLineShelf'
import { PanelHead } from './StudioPanelHead' import { PanelHead, Shelf } from './StudioPanelHead'
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary' import { StudioTabPanel, StudioTabs } from './StudioTabs'
import '../../styles/studio-elements.css' import { StudioStickersPanel } from './StudioStickersPanel'
import { StudioIconShelf } from './sidebar/StudioIconShelf'
import { FRAME_STYLE_OPTIONS, type FrameStyle } from './studioOptions'
import { StudioTextStyles } from './StudioTextStyles' import { StudioTextStyles } from './StudioTextStyles'
import type { ShelfSource } from './textStylesModel' import type { ShelfSource } from './textStylesModel'
/** /**
* Everything you can add that does not come from the journey: type, shapes, * Everything you can add that does not come from the journey: type, shapes,
* picture frames, photo grids and the icon set. * lines, picture frames and grids, the icon set and the stickers.
* *
* Four tabs rather than four more entries on the rail. They are all the same * One panel with six tabs rather than six entries on the rail. They are all
* kind of answer to "what do I put on this page", and a rail long enough to * the same kind of answer to "what do I put on this page", and a rail long
* hold them separately stops being faster to hit than a word would be. * enough to hold them separately stops being faster to hit than a word would
* be. The stickers were a rail section of their own until the rebuild; they
* are one more kind of thing to add, so they sit here beside the icons.
*/ */
// The book's own ids, the ones the layouts and the assistant mint (shared/src/book/book-ids.ts). // The book's own ids, the ones the layouts and the assistant mint (shared/src/book/book-ids.ts).
const uid = bookElementId const uid = bookElementId
/** How many icons the grid draws at a time. Two screens' worth at four across. */ type Tab = 'text' | 'shapes' | 'lines' | 'frames' | 'icons' | 'stickers'
const ICON_PAGE = 120
type FrameStyle = 'none' | 'polaroid' | 'white' | 'shadow' | 'film' | 'tape' /** The tabs, three to a row: a word under each icon, so the longest of them keeps its cell. */
const TABS: readonly { id: Tab; icon: LucideIcon; labelKey: string }[] = [
{ id: 'text', icon: Type, labelKey: 'journey.studio.text' },
{ id: 'shapes', icon: Shapes, labelKey: 'journey.studio.shapes' },
{ id: 'lines', icon: Spline, labelKey: 'journey.studio.lines' },
{ id: 'frames', icon: Frame, labelKey: 'journey.studio.frames' },
{ id: 'icons', icon: Sparkles, labelKey: 'journey.studio.icons' },
{ id: 'stickers', icon: Sticker, labelKey: 'journey.studio.stickers' },
]
/** /**
* A shape as a tile. * A shape as a tile.
@@ -78,103 +92,34 @@ function FrameStyleGlyph({ style }: { style: FrameStyle }) {
}} }}
> >
<span className="st-frame-glyph-photo" style={{ inset: `${pad}px ${pad}px ${bottom}px ${pad}px` }} /> <span className="st-frame-glyph-photo" style={{ inset: `${pad}px ${pad}px ${bottom}px ${pad}px` }} />
{/* Both strips, because both is what gets placed — a preview showing one {/* Both strips, because both is what gets placed: a preview showing one
made the second one look like a bug the first time it appeared. */} made the second one look like a bug the first time it appeared. */}
{style === 'tape' && <><em className="st-frame-glyph-tape" /><em className="st-frame-glyph-tape is-right" /></>} {style === 'tape' && <><em className="st-frame-glyph-tape" /><em className="st-frame-glyph-tape is-right" /></>}
</span> </span>
) )
} }
function IconCell({ name, onPick }: { name: string; onPick: (name: string) => void }) {
const Icon = iconComponent(name)
return (
<button type="button"
className="st-shape-btn is-glyph"
onClick={() => onPick(name)}
aria-label={name}
title={iconLabel(name)}
>
<Icon strokeWidth={1.7} />
</button>
)
}
/** A plain text style: size, weight, its sample words and anything else it sets. */ /** A plain text style: size, weight, its sample words and anything else it sets. */
type AddText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra?: Partial<BookElement>) => void type AddText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra?: Partial<BookElement>) => void
/** The type tab: the four plain styles, then the lockups of the Text styles shelf. */ /**
function TextTab({ page, source, t, addText }: { * What every tab places, and where: centred on the page that is open, across
page: BookPageSetup * both pages of a spread or on the one page of a cover.
source: ShelfSource */
t: (k: string) => string function useElementAdders(page: BookPageSetup) {
addText: AddText
}) {
return (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.text')}</div>
<div className="st-stack">
<button type="button" className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
{t('journey.studio.styleTitle')}
</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
</button>
<button type="button"
className="st-tile"
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
>
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
{t('journey.studio.styleCaption')}
</span>
</button>
</div>
</div>
<StudioTextStyles page={page} source={source} t={t} />
</>
)
}
export function StudioElementsPanel({ page, t, source }: {
page: BookPageSetup
t: (k: string) => string
/** The journal, for the year and the point a lockup is filled with. */
source: ShelfSource
}) {
const [tab, setTab] = useState<'text' | 'shapes' | 'frames' | 'grids' | 'icons'>('text')
const [iconQuery, setIconQuery] = useState('')
/*
* How much of the library is on screen.
*
* Fourteen hundred icons is fourteen hundred React components, each a
* forwardRef around several SVG nodes, and rendering them all costs about a
* second of blocked main thread on the first click of the tab — for a grid
* whose first two rows are all anybody sees. So it grows as it is scrolled,
* and a search resets it: the results of a search are short, and starting
* them part-drawn would be wrong.
*/
const [iconLimit, setIconLimit] = useState(ICON_PAGE)
const iconTail = useRef<HTMLDivElement>(null)
const addElement = useStudioStore(s => s.addElement) const addElement = useStudioStore(s => s.addElement)
const commit = useStudioStore(s => s.commit) const commit = useStudioStore(s => s.commit)
const select = useStudioStore(s => s.select) const select = useStudioStore(s => s.select)
const active = useStudioStore(s => s.activeSpread) const active = useStudioStore(s => s.activeSpread)
const doc = useStudioStore(s => s.doc) const role = useStudioStore(s => s.doc?.spreads[s.activeSpread]?.role)
const spread = doc?.spreads[active] const single = role !== undefined && role !== 'inner'
const single = !!spread && spread.role !== 'inner'
const centre = (w: number, h: number) => { const centre = (w: number, h: number) => {
const W = single ? page.pageWidth : page.pageWidth * 2 const W = single ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h } return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
} }
const addText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra: Partial<BookElement> = {}) => const addText: AddText = (size, weight, sample, extra = {}) =>
addElement(active, { addElement(active, {
id: uid('t'), kind: 'text', frame: centre(page.pageWidth * 0.7, size * 0.5 + 8), id: uid('t'), kind: 'text', frame: centre(page.pageWidth * 0.7, size * 0.5 + 8),
rotation: 0, opacity: 1, locked: false, rotation: 0, opacity: 1, locked: false,
@@ -205,7 +150,7 @@ export function StudioElementsPanel({ page, t, source }: {
: own ? centre(own.w, own.h) : own ? centre(own.w, own.h)
: centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side), : centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side),
rotation: 0, opacity: 1, locked: false, rotation: 0, opacity: 1, locked: false,
// A rule is a shape too — a hairline box rather than its own element type, // A rule is a shape too: a hairline box rather than its own element type,
// so it moves, colours and snaps like everything else on the page. // so it moves, colours and snaps like everything else on the page.
shape: isRule ? 'rect' : shape, shape: isRule ? 'rect' : shape,
fill: opts.outline ? null : '#141414', fill: opts.outline ? null : '#141414',
@@ -218,11 +163,9 @@ export function StudioElementsPanel({ page, t, source }: {
} }
/** /**
* An icon, placed as a square. * An icon, placed as a square. Unlike a shape, an icon keeps its proportions
* * inside whatever frame it is given, so a rectangle would only pad it with
* Square because that is what it is: unlike a shape, an icon keeps its * air on two sides.
* proportions inside whatever frame it is given, so a rectangle would only
* pad it with air on two sides.
*/ */
const addIcon = (name: string) => { const addIcon = (name: string) => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.18 const side = Math.min(page.pageWidth, page.pageHeight) * 0.18
@@ -247,240 +190,232 @@ export function StudioElementsPanel({ page, t, source }: {
} as BookElement) } as BookElement)
} }
const matchedIcons = useMemo(() => searchIcons(iconQuery), [iconQuery]) /** A block of empty frames, selected as a group so it moves and resizes as one thing straight away. */
const shownIcons = useMemo(() => matchedIcons.slice(0, iconLimit), [matchedIcons, iconLimit]) const addGrid = (grid: (typeof GRIDS)[number]) => {
const els = gridElements(grid, defaultGridBox(page, single))
// The page's own cap, as a duplicate keeps it: frames that would make the book unsaveable are not added.
const on = useStudioStore.getState().doc?.spreads[active]
if (!on || on.elements.length + els.length > MAX_SPREAD_ELEMENTS) return
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
}))
select(els.map(e => e.id))
}
/* return { active, single, addElement, addText, addShape, addIcon, addFrame, addGrid }
* Grow the grid when its foot comes into view. }
*
* An observer rather than a scroll handler: the panel is the scroller, the type Adders = ReturnType<typeof useElementAdders>
* foot is the thing that matters, and asking the browser to tell us when they
* meet costs nothing per frame. /** The type tab: the plain styles and the big quotation mark, then the lockups of the Text styles shelf. */
*/ function TextTab({ page, source, t, addText }: {
useEffect(() => { page: BookPageSetup
const tail = iconTail.current source: ShelfSource
if (!tail) return t: (k: string) => string
const io = new IntersectionObserver(entries => { addText: AddText
if (entries.some(e => e.isIntersecting)) setIconLimit(n => n + ICON_PAGE) }) {
}) return (
io.observe(tail) <>
return () => io.disconnect() <Shelf label={t('journey.studio.text')}>
}, [shownIcons.length, matchedIcons.length]) <div className="st-stack">
<button type="button" className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
{t('journey.studio.styleTitle')}
</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
</button>
<button type="button"
className="st-tile"
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
>
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
{t('journey.studio.styleCaption')}
</span>
</button>
{/* A mark of type rather than a shape: it sets in the face the rest of the page is set in. */}
<button type="button"
className="st-tile"
onClick={() => addText(46, 700, '“', { color: '#c9c2b4', leading: 0.9 })}
>
<Quote size={16} strokeWidth={1.6} aria-hidden />
<span>{t('journey.studio.quoteMark')}</span>
</button>
</div>
</Shelf>
<StudioTextStyles page={page} source={source} t={t} />
</>
)
}
/** Every shape by its group, the outline-only rectangle first among the basic ones. */
function ShapesTab({ addShape, t }: { addShape: Adders['addShape']; t: (k: string) => string }) {
return (
<>
{SHAPE_GROUPS.map(group => (
<Shelf key={group.id} label={t(`journey.studio.shapeGroup.${group.id}`)}>
<div className="st-shape-grid">
{group.id === 'basic' && (
<button type="button"
className="st-shape-btn"
onClick={() => addShape('rect', { outline: true })}
title={t('journey.studio.shapeKind.outline')}
aria-label={t('journey.studio.shapeKind.outline')}
>
<Square size={20} strokeWidth={1.1} aria-hidden style={{ opacity: .5 }} />
</button>
)}
{group.shapes.map(shape => (
<button type="button"
key={shape}
className="st-shape-btn is-glyph"
onClick={() => addShape(shape)}
aria-label={shape}
>
<ShapeGlyph shape={shape} />
</button>
))}
</div>
</Shelf>
))}
</>
)
}
/** Empty frames in their styles and shapes, and the grids that lay several of them out at once. */
function FramesTab({ adders, t }: { adders: Adders; t: (k: string) => string }) {
return (
<>
<Shelf label={t('journey.studio.frameStyles')}>
<div className="st-shape-grid">
{FRAME_STYLE_OPTIONS.map(({ value, labelKey }) => (
<button type="button"
key={value}
className="st-shape-btn is-frame"
onClick={() => adders.addFrame(null, value)}
title={t(labelKey)}
aria-label={t(labelKey)}
>
<FrameStyleGlyph style={value} />
</button>
))}
</div>
</Shelf>
<Shelf label={t('journey.studio.frameShapes')} hint={t('journey.studio.frameHint')}>
<div className="st-shape-grid">
{FRAME_SHAPES.map(shape => (
<button type="button"
key={shape}
className="st-shape-btn is-glyph"
onClick={() => adders.addFrame(shape === 'rect' ? null : shape)}
aria-label={shape}
>
<ShapeGlyph shape={shape} photo />
</button>
))}
</div>
</Shelf>
<Shelf label={t('journey.studio.grids')} hint={t('journey.studio.gridHint')}>
<div className="st-grid-grid">
{GRIDS.map(grid => (
<button
type="button"
key={grid.id}
className="st-grid-btn"
onClick={() => adders.addGrid(grid)}
aria-label={grid.id}
title={`${grid.cells.length}`}
>
{/* The arrangement itself: "hero-two" means nothing until you
have seen it, and a diagram is faster to read than a name. */}
<span className="st-grid-preview">
{grid.cells.map((cell, i) => (
<span
key={i}
style={{
left: `${cell.x * 100}%`,
top: `${cell.y * 100}%`,
width: `${cell.w * 100}%`,
height: `${cell.h * 100}%`,
}}
/>
))}
</span>
</button>
))}
</div>
</Shelf>
</>
)
}
export function StudioElementsPanel({ page, t, source }: {
page: BookPageSetup
t: (k: string) => string
/** The journal, for the year and the point a lockup is filled with. */
source: ShelfSource
}) {
const [tab, setTab] = useState<Tab>('text')
const tabsId = useId()
const adders = useElementAdders(page)
return ( return (
<> <>
<PanelHead label={t('journey.studio.elements')} /> <PanelHead label={t('journey.studio.elements')} />
<div className="st-tabs is-fluid"> <StudioTabs<Tab>
<button type="button" className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}> idBase={tabsId}
{t('journey.studio.text')} label={t('journey.studio.elements')}
</button> value={tab}
<button type="button" className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}> onChange={setTab}
{t('journey.studio.shapes')} columns={3}
</button> tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))}
<button type="button" className={tab === 'frames' ? 'is-on' : ''} onClick={() => setTab('frames')}> />
{t('journey.studio.frames')}
</button>
<button type="button" className={tab === 'grids' ? 'is-on' : ''} onClick={() => setTab('grids')}>
{t('journey.studio.grids')}
</button>
<button type="button" className={tab === 'icons' ? 'is-on' : ''} onClick={() => setTab('icons')}>
{t('journey.studio.icons')}
</button>
</div>
<div className="st-panel-scroll"> <StudioTabPanel idBase={tabsId} value={tab} className="st-tab-body">
{/* {/*
Type first: the plain styles, then the lockups, set titles and marks The icons and the stickers carry a search of their own, which sits
drawn with the book's own words (StudioTextStyles.tsx). above their scrolling grid like the Content panel's above its own;
the other tabs are short shelves and scroll as they are.
*/} */}
{tab === 'text' && <TextTab page={page} source={source} t={t} addText={addText} />} {tab === 'icons' ? (
<StudioIconShelf onPick={adders.addIcon} t={t} />
{tab === 'shapes' && ( ) : tab === 'stickers' ? (
<> <StudioStickersPanel page={page} t={t} />
<div className="st-section"> ) : (
<div className="st-section-label">{t('journey.studio.lines')}</div> <div className="st-panel-scroll">
<div className="st-shape-grid"> {tab === 'text' && <TextTab page={page} source={source} t={t} addText={adders.addText} />}
<button type="button" className="st-shape-btn" onClick={() => addShape('line')} title={t('journey.studio.shapeKind.line')}> {tab === 'shapes' && <ShapesTab addShape={adders.addShape} t={t} />}
<Minus size={20} strokeWidth={2} /> {tab === 'lines' && (
</button> <StudioLineShelf
<button type="button" page={page}
className="st-shape-btn" single={adders.single}
onClick={() => addShape('rect', { outline: true })} onAdd={el => adders.addElement(adders.active, el)}
title={t('journey.studio.shapeKind.outline')} t={t}
> lead={(
<Square size={20} strokeWidth={1.1} style={{ opacity: .5 }} />
</button>
<button type="button"
className="st-shape-btn"
onClick={() => addText(46, 700, '“', { color: '#c9c2b4', leading: 0.9 })}
title={t('journey.studio.quoteMark')}
>
<Quote size={20} strokeWidth={1.6} />
</button>
</div>
</div>
<StudioLineShelf page={page} single={single} onAdd={el => addElement(active, el)} t={t} />
{SHAPE_GROUPS.map(group => (
<div className="st-section" key={group.id}>
<div className="st-section-label">{t(`journey.studio.shapeGroup.${group.id}`)}</div>
<div className="st-shape-grid">
{group.shapes.map(shape => (
<button type="button"
key={shape}
className="st-shape-btn is-glyph"
onClick={() => addShape(shape)}
aria-label={shape}
>
<ShapeGlyph shape={shape} />
</button>
))}
</div>
</div>
))}
</>
)}
{tab === 'frames' && (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.frameStyles')}</div>
<div className="st-shape-grid">
{([
['none', 'plainFrame'],
['polaroid', 'polaroidFrame'],
['white', 'whiteFrame'],
['shadow', 'shadowFrame'],
['film', 'filmFrame'],
['tape', 'tapeFrame'],
] as [FrameStyle, string][]).map(([style, key]) => (
<button type="button" <button type="button"
key={style} className="st-shape-btn"
className="st-shape-btn is-frame" onClick={() => adders.addShape('line')}
onClick={() => addFrame(null, style)} title={t('journey.studio.shapeKind.line')}
title={t(`journey.studio.${key}`)} aria-label={t('journey.studio.shapeKind.line')}
aria-label={t(`journey.studio.${key}`)}
> >
<FrameStyleGlyph style={style} /> <Minus size={20} strokeWidth={2} aria-hidden />
</button> </button>
))} )}
</div>
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.frameShapes')}</div>
<div className="st-shape-grid">
{FRAME_SHAPES.map(shape => (
<button type="button"
key={shape}
className="st-shape-btn is-glyph"
onClick={() => addFrame(shape === 'rect' ? null : shape)}
aria-label={shape}
>
<ShapeGlyph shape={shape} photo />
</button>
))}
</div>
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.frameHint')}</p>
</div>
</>
)}
{tab === 'grids' && (
<div className="st-section">
<div className="st-section-label">{t('journey.studio.grids')}</div>
<div className="st-grid-grid">
{GRIDS.map(grid => (
<button
type="button"
key={grid.id}
className="st-grid-btn"
onClick={() => {
const els = gridElements(grid, defaultGridBox(page, single))
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) =>
(i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
}))
// Selected as a group, so the block can be moved or resized
// as one thing straight away rather than cell by cell.
select(els.map(e => e.id))
}}
aria-label={grid.id}
title={`${grid.cells.length}`}
>
{/* The arrangement itself: "hero-two" means nothing until you
have seen it, and a diagram is faster to read than a name. */}
<span className="st-grid-preview">
{grid.cells.map((cell, i) => (
<span
key={i}
style={{
left: `${cell.x * 100}%`,
top: `${cell.y * 100}%`,
width: `${cell.w * 100}%`,
height: `${cell.h * 100}%`,
}}
/>
))}
</span>
</button>
))}
</div>
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.gridHint')}</p>
</div>
)}
{tab === 'icons' && (
<div className="st-section">
{/*
The search sits inside the scroller rather than above the tab
strip, unlike the Content panel's: that one filters both of its
tabs, this one belongs to a single tab, and hanging it above the
strip would make the strip move when you switch away from it.
*/}
<div className="st-search" style={{ margin: '0 0 10px' }}>
<Search size={14} />
<input
value={iconQuery}
onChange={e => { setIconQuery(e.target.value); setIconLimit(ICON_PAGE) }}
placeholder={t('journey.studio.searchIcons')}
aria-label={t('journey.studio.searchIcons')}
spellCheck={false}
/> />
{iconQuery && (
<button type="button" onClick={() => { setIconQuery(''); setIconLimit(ICON_PAGE) }} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
{/* The shelf, until somebody searches. */}
{!iconQuery && (
<>
<div className="st-section-label">{t('journey.studio.iconsForTravel')}</div>
<div className="st-shape-grid" style={{ marginBottom: 14 }}>
{FEATURED_ICONS.map(name => (
<IconCell key={name} name={name} onPick={addIcon} />
))}
</div>
<div className="st-section-label">{t('journey.studio.iconsAll')}</div>
</>
)}
<div className="st-shape-grid">
{shownIcons.map(name => (
<IconCell key={name} name={name} onPick={addIcon} />
))}
</div>
{/* What the observer below watches for. */}
{shownIcons.length < matchedIcons.length && <div ref={iconTail} style={{ height: 1 }} />}
{!matchedIcons.length && (
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.noMatches')}</p>
)} )}
{tab === 'frames' && <FramesTab adders={adders} t={t} />}
</div> </div>
)} )}
</div> </StudioTabPanel>
</> </>
) )
} }
+160 -709
View File
@@ -1,36 +1,25 @@
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared' import type { BookElement, BookPageNumbers, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
import { import { withThemeLinks } from '@trek/shared'
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp, import { ClipboardCopy, Copy, Lock, Trash2, Unlock } from 'lucide-react'
ChevronsDown, ChevronsUp, ClipboardCopy, Copy, Italic, Lock, Trash2, Unlock, import type { ReactNode } from 'react'
} from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { useStudioStore } from '../../store/studioStore' import { useStudioStore } from '../../store/studioStore'
import { photoSrc } from './bookRender' import { Tooltip } from '../shared/Tooltip'
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes' import '../../styles/studio-panels.css'
import { ShapeGlyph } from './StudioElementsPanel' import { PanelHead } from './StudioPanelHead'
import { selectionTitle } from './studioLayerRows'
import { StudioArrangeSection } from './StudioArrangeSection'
import { TravelInspector } from './StudioTravelInspector' import { TravelInspector } from './StudioTravelInspector'
import { StudioStickerInspector } from './StudioStickerInspector' import { StudioStickerInspector } from './StudioStickerInspector'
import { Swatches } from './StudioSwatches'
import { StudioBackgroundSection } from './StudioBackgroundSection'
import { StudioShapePaint } from './StudioShapePaint'
import { MAX_BOOK_TEXT_SIZE, formatBookCoords, withThemeLinks } from '@trek/shared'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
import { italicTitle } from './fontPicker'
import { PhotoCropExtras, PhotoLookSections } from './photo/StudioPhotoLook'
import { StudioTextEffects } from './StudioTextEffects'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
import { StudioArrangeSection } from './StudioArrangeSection'
import { selectionTitle } from './studioLayerRows'
import { EdgeSection } from './StudioEdgeControls'
import { LineInspector } from './StudioLineInspector' import { LineInspector } from './StudioLineInspector'
import { StudioThemeLink } from './theme/StudioThemeLink' import { StudioDocumentInspector } from './inspector/StudioDocumentInspector'
import { StudioPositionSection } from './inspector/StudioPositionSection'
/** How many icons the swap grid offers at once. A search narrows it below this. */ import { StudioTextInspector } from './inspector/StudioTextInspector'
const ICON_CHOICES = 60 import { StudioPhotoInspector } from './inspector/StudioPhotoInspector'
import { StudioShapeInspector } from './inspector/StudioShapeInspector'
import { StudioIconInspector } from './inspector/StudioIconInspector'
import { selectionKey } from './inspector/inspectorModel'
import type { RailSection } from './sidebar/railSections'
import type { JourneySource } from './StudioSidebar'
/** /**
* Properties of whatever is selected. * Properties of whatever is selected.
@@ -42,24 +31,27 @@ const ICON_CHOICES = 60
* out reads as a settings dialog, not as a tool. * out reads as a settings dialog, not as a tool.
* *
* The order is the same for every kind, because the panel is used by muscle * The order is the same for every kind, because the panel is used by muscle
* memory more than by reading: **what it says** first, then **how it looks**, * memory more than by reading:
* then **where it sits**, then **what is in front of what**. A photo's crop and
* a mark's words are the same step in that sequence, so they sit in the same
* place on the panel.
* *
* The frame's numbers are folded away by default. They are the least-used group * 1. **what it is and what it says**: a text's words, a photo's crop, a
* in the panel — dragging is how anyone actually positions an element — and * shape's outline, a sticker's colours, a map's scope and framing;
* four number fields left open at the top pushed everything worth looking at * 2. **where it sits**: the position and size, folded unless the element is
* below the fold. * turned or faded (or is a sticker, which is turned as a matter of course),
* and for several at once their angle and opacity without the frame;
* 3. **how it looks**: type, colour, fill, frame;
* 4. **the effects**: folded, and open on an element that uses them;
* 5. **arrange**: lining up, stacking, grouping, the style clipboard.
* *
* ── What is not here any more ──────────────────────────────────────────── * Every group mounts afresh for each selection (inspector/inspectorModel.ts),
* so a folded group opens for the element that uses it and closes again for
* the next one, and nothing is remembered behind the person's back.
* *
* Duplicate, lock and delete are in the panel head rather than in a section of * Duplicate, lock and delete are in the panel head rather than in a group of
* their own: they act on the element rather than describing it, and a group * their own: they act on the element rather than describing it.
* called "Arrange" that held two of those next to the stacking buttons was the
* one group in the panel whose name did not say what was inside it.
*/ */
type Set = (patch: Partial<BookElement>) => void
export function StudioInspector({ export function StudioInspector({
spreadIndex, spreadIndex,
page, page,
@@ -69,6 +61,7 @@ export function StudioInspector({
setPageNumbers, setPageNumbers,
t, t,
locale, locale,
onOpenPanel,
}: { }: {
spreadIndex: number spreadIndex: number
page: BookPageSetup page: BookPageSetup
@@ -81,126 +74,35 @@ export function StudioInspector({
source: JourneySource source: JourneySource
t: (k: string) => string t: (k: string) => string
locale: string locale: string
/** Opens a panel of the rail on the other side: the Theme panel, or the Layers panel beside the selection. */
onOpenPanel?: (section: RailSection) => void
}) { }) {
const [iconQuery, setIconQuery] = useState('')
/*
* A page of results, memoised because the inspector re-renders on every
* pointer move of a drag and the library is fourteen hundred names long.
*/
const iconChoices = useMemo(() => searchIcons(iconQuery).slice(0, ICON_CHOICES), [iconQuery])
const doc = useStudioStore(s => s.doc) const doc = useStudioStore(s => s.doc)
const selection = useStudioStore(s => s.selection) const selection = useStudioStore(s => s.selection)
const update = useStudioStore(s => s.updateElement) const update = useStudioStore(s => s.updateElement)
const commit = useStudioStore(s => s.commit) const commit = useStudioStore(s => s.commit)
const raise = useStudioStore(s => s.raise)
const duplicate = useStudioStore(s => s.duplicate)
const copy = useStudioStore(s => s.copy)
const removeElements = useStudioStore(s => s.removeElements)
const spread = doc?.spreads[spreadIndex] const spread = doc?.spreads[spreadIndex]
const sel = spread?.elements.filter(e => selection.includes(e.id)) ?? [] const sel = spread?.elements.filter(e => selection.includes(e.id)) ?? []
const el = sel[0]
if (!sel.length) { if (!el) {
/*
* Nothing selected, so this is where the *book's* own settings belong.
* The panel was showing one line of hint text and nothing else, and page
* numbers have nowhere else to go: they are not a property of any element,
* because the number a page carries depends on where its spread sits.
*/
const folios = page.pageNumbers
return ( return (
<aside className="st-panel st-inspector"> <StudioDocumentInspector
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div> spreadIndex={spreadIndex}
<div className="st-panel-scroll"> page={page}
<StudioBackgroundSection spreadIndex={spreadIndex} page={page} source={source} t={t} /> source={source}
setPageNumbers={setPageNumbers}
<Section label={t('journey.studio.pageNumbers')}> onOpenTheme={onOpenPanel && (() => onOpenPanel('theme'))}
<Switch t={t}
label={t('journey.studio.pageNumbers')} />
on={folios.show}
onToggle={() => setPageNumbers({ show: !folios.show })}
/>
{folios.show && (
<>
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.position')}>
<Choice
value={folios.position}
options={(['outer', 'inner', 'centre'] as const).map(pos => ({
value: pos, label: t(`journey.studio.folio.${pos}`),
}))}
onPick={position => setPageNumbers({ position })}
/>
</Line>
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.folioStart')}
value={folios.startAt}
min={0}
max={9999}
step={1}
onChange={v => setPageNumbers({ startAt: Math.round(v) })}
/>
<NumField
label={t('journey.studio.size')}
value={folios.size}
min={4}
max={48}
step={0.5}
unit="pt"
onChange={v => setPageNumbers({ size: v })}
/>
</div>
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.folioMargin')}
value={folios.margin}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={v => setPageNumbers({ margin: v })}
/>
</div>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.folioAuto')}
on={folios.autoColor}
onToggle={() => setPageNumbers({ autoColor: !folios.autoColor })}
/>
</div>
{/* Picking a colour is the other way to turn automatic off, so
the row stays reachable and simply reads as inactive. */}
<div style={{ marginTop: 10, opacity: folios.autoColor ? 0.45 : 1 }}>
<Line label={t('journey.studio.colour')} wide>
<Swatches
value={folios.color}
onPick={c => setPageNumbers({ color: c, autoColor: false })}
/>
</Line>
</div>
</>
)}
</Section>
<p className="st-hint" style={{ paddingTop: 10 }}>
{t('journey.studio.inspectorEmpty')}
</p>
<p className="st-hint" style={{ paddingTop: 6 }}>
{t('journey.studio.pageHint')} {page.pageWidth} × {page.pageHeight} mm
</p>
</div>
</aside>
) )
} }
const el = sel[0]
const one = sel.length === 1 const one = sel.length === 1
// A property edit is one undo step on its own; only drags open a gesture. // A property edit is one undo step on its own; only drags open a gesture.
const set = (patch: Partial<BookElement>) => commit(d => ({ const set: Set = patch => commit(d => ({
...d, ...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : { spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp, ...sp,
@@ -209,581 +111,130 @@ export function StudioInspector({
})), })),
})) }))
const live = (patch: Partial<BookElement>) => update(spreadIndex, el.id, patch) const live: Set = patch => update(spreadIndex, el.id, patch)
/**
* Stretch the picture over the page it is on, or over both pages, bleed
* included.
*
* One commit rather than `set` followed by `raise`: the frame, the rotation
* put back to zero (a turned picture leaves bare corners), the cover crop and
* the move to the back are one press, and one press comes back with one
* undo. Which page is the one under the frame's centre; a cover has only
* the one.
*/
const fill = (scope: 'page' | 'spread') => commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => {
if (i !== spreadIndex) return sp
const bleed = page.bleed
const onRight = sp.role === 'inner' && scope === 'page'
&& el.frame.x + el.frame.w / 2 >= page.pageWidth
const frame = {
x: onRight ? page.pageWidth - bleed : -bleed,
y: -bleed,
w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed,
h: page.pageHeight + 2 * bleed,
}
const filled = { ...el, frame, rotation: 0, fit: 'cover' } as BookElement
return { ...sp, elements: [filled, ...sp.elements.filter(e => e.id !== el.id)] }
}),
}))
return ( return (
<aside className="st-panel st-inspector"> <aside className="st-panel st-inspector">
{/* {/*
The head says what is selected and carries the three things you do TO The head says what is selected and carries the things you do TO it, in
it. It used to say only the kind, in the same small capitals as every the head every panel opens on.
section label below it, which left the panel with no top.
*/} */}
<div className="st-panel-head st-inspector-head"> <PanelHead
<span className="st-what"> label={one ? t(`journey.studio.kind.${el.kind}`) : selectionTitle(spread, selection, t)}
{one ? t(`journey.studio.kind.${el.kind}`) : selectionTitle(spread, selection, t)} count={sel.length > 1 ? sel.length : undefined}
{sel.length > 1 && <em>{sel.length}</em>} actions={<SelectionActions spreadIndex={spreadIndex} el={el} selection={selection} set={set} t={t} />}
</span> />
<span className="st-head-acts">
<Tooltip label={t('journey.studio.copyToPage')}>
<button type="button"
className="st-act"
onClick={() => copy(spreadIndex, selection)}
aria-label={t('journey.studio.copyToPage')}
>
<ClipboardCopy size={14} />
</button>
</Tooltip>
<button type="button"
className="st-act"
onClick={() => duplicate(spreadIndex, selection)}
title={t('journey.studio.duplicate')}
aria-label={t('journey.studio.duplicate')}
>
<Copy size={14} />
</button>
<button type="button"
className={`st-act ${el.locked ? 'is-on' : ''}`}
onClick={() => set({ locked: !el.locked })}
title={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
aria-label={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
aria-pressed={el.locked}
>
{el.locked ? <Lock size={14} /> : <Unlock size={14} />}
</button>
<button type="button"
className="st-act is-danger"
onClick={() => removeElements(spreadIndex, selection)}
title={t('journey.studio.delete')}
aria-label={t('journey.studio.delete')}
>
<Trash2 size={14} />
</button>
</span>
</div>
<div className="st-panel-scroll"> <div className="st-panel-scroll" key={selectionKey(selection)}>
{el.kind === 'text' && ( <KindInspector
<> el={el}
<Section label={t('journey.studio.text')}> one={one}
<textarea spreadIndex={spreadIndex}
className="st-input st-textarea" spread={spread}
value={el.text} page={page}
rows={5} selection={selection}
onChange={e => live({ text: e.target.value, overridden: true })} stats={stats}
onBlur={e => set({ text: e.target.value, overridden: true })} path={path}
/> source={source}
{el.binding && !el.overridden && ( set={set}
<p className="st-hint" style={{ paddingTop: 6 }}>{t('journey.studio.boundHint')}</p> live={live}
)} position={<StudioPositionSection el={el} sel={sel} set={set} t={t} />}
{/* t={t}
How a point is set. Only a coordinate binding has the question, locale={locale}
and answering it re-sets the words on the spot rather than at />
the next open — switching a notation and seeing nothing happen <StudioArrangeSection
reads as a broken control. Deliberately not `overridden`: spreadIndex={spreadIndex}
choosing a format is not writing the text. t={t}
*/} onShowLayers={onOpenPanel && (() => onOpenPanel('layers'))}
{el.binding?.source === 'entry.location' && el.binding.format && ( />
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.coordsMark')}>
<Choice
value={el.binding.format}
options={[
{ value: 'dms' as const, label: t('journey.studio.coordsDms') },
{ value: 'decimal' as const, label: t('journey.studio.coordsDecimal') },
]}
onPick={f => {
const entry = source.entries.find(e => e.id === el.binding!.entryId)
const text = entry?.lat != null && entry.lng != null
? formatBookCoords(entry.lat, entry.lng, f)
: el.text
set({ binding: { ...el.binding!, format: f }, text })
}}
/>
</Line>
</div>
)}
</Section>
<Section label={t('journey.studio.typography')}>
{/*
Each family set in itself, because a list of names tells you
nothing about a typeface — and grouped, because "which serif"
is a different question from "serif or sans".
*/}
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch)} t={t} />
<div style={{ marginTop: 10 }}>
<WeightLine font={el.font} weight={el.weight} onPick={weight => set({ weight })} t={t} />
<Line label={t('journey.studio.align')}>
<div className="st-row">
{([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
<button type="button"
key={a}
className={`st-chip is-icon ${el.align === a ? 'is-on' : ''}`}
onClick={() => set({ align: a })}
aria-label={a}
>
<Icon size={14} />
</button>
))}
{/* Italic sits with the alignment because both are things
you do to a line rather than to a typeface. It had no
control at all before, though the renderer drew it. */}
<button type="button"
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
onClick={() => set({ italic: !el.italic })}
title={italicTitle(el.font, el.weight, t)}
aria-label={t('journey.studio.italic')}
aria-pressed={el.italic}
>
<Italic size={14} />
</button>
</div>
</Line>
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField label={t('journey.studio.size')} value={el.size} min={4} max={MAX_BOOK_TEXT_SIZE} step={0.5} unit="pt" onChange={v => set({ size: v })} />
<NumField label={t('journey.studio.leading')} value={el.leading} min={0.7} max={3} step={0.05} onChange={v => set({ leading: v })} />
</div>
<div style={{ marginTop: 10 }}>
{/*
Letter spacing was rendered but had no control, so type the
layout had tracked out could be edited in every way except the
one that made it look like that.
*/}
<NumField
label={t('journey.studio.tracking')}
value={el.tracking}
min={-0.2}
max={1.5}
step={0.01}
unit="em"
onChange={v => set({ tracking: v })}
/>
</div>
</Section>
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.text')} wide>
<Swatches value={el.color} onPick={c => set({ color: c })} />
<StudioThemeLink theme={doc?.theme} token={el.token} t={t}
onLink={(token, color) => set({ color, token })} onUnlink={() => set({ token: undefined })} />
</Line>
</Section>
<StudioTextEffects el={el} set={set} live={live} photos={source.photos} spread={spread} t={t} />
</>
)}
{el.kind === 'photo' && (
<>
<Section label={t('journey.studio.crop')}>
<Line label={t('journey.studio.crop')} wide>
<Choice
value={el.fit}
options={(['cover', 'contain'] as const).map(f => ({
value: f, label: t(`journey.studio.fit.${f}`),
}))}
onPick={fit => set({ fit })}
/>
</Line>
{/*
An empty frame has no picture to aim at, and asking for one by
id produced a request for /api/photos/null/thumbnail and a
broken image under a caption telling you to drag it.
*/}
{el.photoId != null ? (
<FocalPad
photoId={el.photoId}
x={el.focalX}
y={el.focalY}
onDrag={(x, y) => live({ focalX: x, focalY: y })}
onCommit={(x, y) => set({ focalX: x, focalY: y })}
hint={t('journey.studio.focalHint')}
/>
) : (
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.emptyFrame')}</p>
)}
<PhotoCropExtras el={el} set={set} live={live} t={t} />
</Section>
<Section label={t('journey.studio.look')}>
<Line label={t('journey.studio.look')} wide>
<Choice
value={el.filter}
options={(['none', 'bw', 'warm', 'cool', 'fade', 'contrast'] as const).map(f => ({
value: f, label: t(`journey.studio.filter.${f}`),
}))}
onPick={filter => set({ filter })}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.frameStyle')} wide>
<Choice
value={el.frameStyle}
options={([
['none', 'plainFrame'],
['polaroid', 'polaroidFrame'],
['white', 'whiteFrame'],
['shadow', 'shadowFrame'],
['film', 'filmFrame'],
['tape', 'tapeFrame'],
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
onPick={frameStyle => set({ frameStyle })}
/>
</Line>
</div>
{/* A corner radius and a mask are the same idea at different
resolutions, so the radius steps aside once a shape is cut.
It did not, and edited a property the renderer ignored. */}
{(!el.mask || el.mask === 'rect') && (
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.radius')}
value={el.radius}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={v => set({ radius: v })}
/>
</div>
)}
</Section>
<PhotoLookSections el={el} set={set} live={live} t={t} />
<Section label={t('journey.studio.mask')} defaultOpen={false}>
<div className="st-mini-shapes">
<button type="button"
className={`st-mini-shape ${el.mask ? '' : 'is-on'}`}
onClick={() => set({ mask: null })}
title={t('journey.studio.maskNone')}
>
<span className="st-mini-none" />
</button>
{FRAME_SHAPES.filter(sh => sh !== 'rect').map(sh => (
<button type="button"
key={sh}
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
onClick={() => set({ mask: sh })}
aria-label={sh}
>
<ShapeGlyph shape={sh} />
</button>
))}
</div>
</Section>
<EdgeSection
edge={el.edge}
edgeSize={el.edgeSize}
outline={el.mask && el.mask !== 'rect' ? el.mask : null}
onSet={patch => set(patch)}
t={t}
/>
</>
)}
{el.kind === 'shape' && (
<>
<Section label={t('journey.studio.shape')}>
{/* Every shape, not just the two the element started as: swapping
a placed shape keeps its position, size and colour, which
deleting and re-adding would not. */}
<div className="st-mini-shapes">
{SHAPE_GROUPS.flatMap(g => g.shapes).map((sh: BookShapeId) => (
<button type="button"
key={sh}
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
onClick={() => set({ shape: sh })}
aria-label={sh}
>
<ShapeGlyph shape={sh} />
</button>
))}
</div>
<div style={{ marginTop: 10 }}>
{/* A photo had a corner radius and a shape did not, although
both store the same field and the renderer draws both. */}
<NumField
label={t('journey.studio.radius')}
value={el.radius}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={v => set({ radius: v })}
/>
</div>
</Section>
<StudioShapePaint
el={el}
spreadIndex={spreadIndex}
selection={selection}
onStroke={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
theme={doc?.theme}
set={set}
t={t}
/>
<EdgeSection edge={el.edge} edgeSize={el.edgeSize} outline={el.shape} onSet={patch => set(patch)} t={t} />
<Section label={t('journey.studio.strokeStyle')} defaultOpen={false}>
<Line label={t('journey.studio.strokeStyle')} wide>
<Choice
value={el.strokeStyle}
options={([
['solid', 'strokeSolid'],
['dashed', 'strokeDashed'],
['dotted', 'strokeDotted'],
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
onPick={strokeStyle => set({
strokeStyle,
// A stroke style with no stroke is invisible, so asking for
// dashes turns the outline on rather than doing nothing.
...(el.stroke ? {} : { stroke: el.fill ?? '#141414', strokeWidth: el.strokeWidth || 0.5 }),
})}
/>
</Line>
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.strokeWidth')}
value={el.strokeWidth}
min={0}
max={20}
step={0.1}
unit="mm"
onChange={v => set({ strokeWidth: v, ...(v > 0 && !el.stroke ? { stroke: '#141414' } : {}) })}
/>
</div>
</Section>
</>
)}
{el.kind === 'icon' && (
<>
<Section label={t('journey.studio.icon')}>
{/*
A search rather than the whole library: the picker in the panel
can afford to grow as it is scrolled, but the inspector is a
column beside a page and its job here is swapping one drawing
for a near neighbour. Swapping keeps the position, size and
colour that deleting and re-placing would lose.
*/}
<input
className="st-input"
value={iconQuery}
onChange={e => setIconQuery(e.target.value)}
placeholder={t('journey.studio.searchIcons')}
aria-label={t('journey.studio.searchIcons')}
spellCheck={false}
/>
<div className="st-mini-shapes" style={{ marginTop: 8 }}>
{iconChoices.map(name => {
const Glyph = iconComponent(name)
return (
<button type="button"
key={name}
className={`st-mini-shape ${el.name === name ? 'is-on' : ''}`}
onClick={() => set({ name })}
aria-label={name}
title={iconLabel(name)}
>
<Glyph strokeWidth={1.7} />
</button>
)
})}
</div>
</Section>
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.icon')} wide>
<Swatches value={el.color} onPick={c => set({ color: c })} />
<StudioThemeLink theme={doc?.theme} token={el.token} t={t}
onLink={(token, color) => set({ color, token })} onUnlink={() => set({ token: undefined })} />
</Line>
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.lineWidth')}
value={el.lineWidth}
min={0.25}
max={4}
step={0.25}
onChange={v => set({ lineWidth: v })}
/>
</div>
</Section>
</>
)}
{el.kind === 'sticker' && <StudioStickerInspector el={el} set={set} t={t} />}
{el.kind === 'line' && <LineInspector el={el} spreadIndex={spreadIndex} t={t} />}
<TravelInspector el={el} stats={stats} path={path} set={set} t={t} locale={locale} />
{/*
Where it sits, folded away.
Everything in here can be done by dragging, which is how it is
actually done; the numbers are for the one time in ten you need the
same margin on two pages. Opacity and rotation live here too — both
were painted by the renderer and neither had a control anywhere.
*/}
{one && (
<Section label={t('journey.studio.position')} defaultOpen={false}>
<div className="st-grid2">
<NumField label="X" value={el.frame.x} unit="mm" onChange={v => set({ frame: { ...el.frame, x: v } })} />
<NumField label="Y" value={el.frame.y} unit="mm" onChange={v => set({ frame: { ...el.frame, y: v } })} />
<NumField label={t('journey.studio.width')} value={el.frame.w} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, w: v } })} />
<NumField label={t('journey.studio.height')} value={el.frame.h} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, h: v } })} />
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.rotation')}
value={el.rotation}
min={-180}
max={180}
step={1}
unit="°"
onChange={v => set({ rotation: v })}
/>
<NumField
label={t('journey.studio.opacity')}
value={Math.round(el.opacity * 100)}
min={0}
max={100}
step={5}
unit="%"
onChange={v => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
/>
</div>
</Section>
)}
<StudioArrangeSection spreadIndex={spreadIndex} t={t} />
<Section label={t('journey.studio.arrange')} defaultOpen={false}>
<div className="st-row">
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'up')} title={t('journey.studio.forward')}>
<ArrowUp size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'down')} title={t('journey.studio.backward')}>
<ArrowDown size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</div>
{/*
The picture as the page.
A full-bleed photograph is the commonest layout in any photo book
and the hardest to make by hand: the frame has to reach exactly to
the bleed on every side, and a millimetre short leaves a white
hairline on the trimmed page. So it is one press. It goes to the
back as well, since a page-sized picture in front of everything is
a page with nothing else on it.
*/}
{one && el.kind === 'photo' && (
<div className="st-row" style={{ marginTop: 6 }}>
<button type="button" className="st-chip" onClick={() => fill('page')} title={t('journey.studio.fillHint')}>
{t('journey.studio.fillPage')}
</button>
{spread.role === 'inner' && (
<button type="button" className="st-chip" onClick={() => fill('spread')} title={t('journey.studio.fillHint')}>
{t('journey.studio.fillSpread')}
</button>
)}
</div>
)}
</Section>
</div> </div>
</aside> </aside>
) )
} }
/** /** Copy to another page, duplicate, lock and delete: what you do to the selection, in the head. */
* Where the picture is anchored inside its frame. function SelectionActions({ spreadIndex, el, selection, set, t }: {
* spreadIndex: number
* Two sliders were the first draft and they were the wrong instrument: a focal el: BookElement
* point is one position in a plane, not two unrelated numbers, and nobody can selection: string[]
* predict what "X 0.62" does to a photograph. Dragging the point over the actual set: Set
* image answers the question by showing it. t: (k: string) => string
*/
export function FocalPad({
photoId, x, y, onDrag, onCommit, hint,
}: {
photoId: number
x: number
y: number
onDrag: (x: number, y: number) => void
onCommit: (x: number, y: number) => void
hint: string
}) { }) {
const box = useRef<HTMLDivElement>(null) const duplicate = useStudioStore(s => s.duplicate)
const last = useRef({ x, y }) const copy = useStudioStore(s => s.copy)
const removeElements = useStudioStore(s => s.removeElements)
const from = (e: React.PointerEvent) => { const lockLabel = t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')
const r = box.current!.getBoundingClientRect()
const nx = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
const ny = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))
last.current = { x: nx, y: ny }
return last.current
}
return ( return (
<div className="st-focal-wrap"> <>
<div <Tooltip label={t('journey.studio.copyToPage')}>
className="st-focal" <button type="button" className="st-act" onClick={() => copy(spreadIndex, selection)}
ref={box} aria-label={t('journey.studio.copyToPage')}>
onPointerDown={e => { <ClipboardCopy size={14} />
(e.target as Element).setPointerCapture(e.pointerId) </button>
const p = from(e) </Tooltip>
onDrag(p.x, p.y) <button type="button" className="st-act" onClick={() => duplicate(spreadIndex, selection)}
}} title={t('journey.studio.duplicate')} aria-label={t('journey.studio.duplicate')}>
onPointerMove={e => { <Copy size={14} />
if (!e.currentTarget.hasPointerCapture?.(e.pointerId)) return </button>
const p = from(e) <button type="button" className={`st-act ${el.locked ? 'is-on' : ''}`} onClick={() => set({ locked: !el.locked })}
onDrag(p.x, p.y) title={lockLabel} aria-label={lockLabel} aria-pressed={el.locked}>
}} {el.locked ? <Lock size={14} /> : <Unlock size={14} />}
onPointerUp={() => onCommit(last.current.x, last.current.y)} </button>
> <button type="button" className="st-act is-danger" onClick={() => removeElements(spreadIndex, selection)}
<img src={photoSrc(photoId, false)} alt="" draggable={false} /> title={t('journey.studio.delete')} aria-label={t('journey.studio.delete')}>
<span className="st-focal-dot" style={{ left: `${x * 100}%`, top: `${y * 100}%` }} /> <Trash2 size={14} />
</div> </button>
<p className="st-hint" style={{ padding: '6px 0 0' }}>{hint}</p> </>
</div>
) )
} }
/** The groups of the selected kind, each placing the position group after what the element is. */
function KindInspector({ el, one, spreadIndex, spread, page, selection, stats, path, source, set, live, position, t, locale }: {
el: BookElement
one: boolean
spreadIndex: number
spread: BookSpread | undefined
page: BookPageSetup
selection: string[]
stats: JourneyStats | null
path: [number, number][][]
source: JourneySource
set: Set
live: Set
position: ReactNode
t: (k: string) => string
locale: string
}) {
const theme = useStudioStore(s => s.doc?.theme)
switch (el.kind) {
case 'text':
return (
<StudioTextInspector el={el} set={set} live={live} source={source} spread={spread} theme={theme}
position={position} t={t} />
)
case 'photo':
return (
<StudioPhotoInspector el={el} one={one} spreadIndex={spreadIndex} page={page} set={set} live={live}
position={position} t={t} />
)
case 'shape':
return (
<StudioShapeInspector el={el} spreadIndex={spreadIndex} selection={selection} theme={theme} set={set}
position={position} t={t} />
)
case 'icon':
return <StudioIconInspector el={el} theme={theme} set={set} position={position} t={t} />
case 'sticker':
return <StudioStickerInspector el={el} set={set} position={position} t={t} />
case 'line':
return <LineInspector el={el} spreadIndex={spreadIndex} position={position} t={t} />
case 'map':
case 'stats':
case 'countries':
case 'badge':
case 'list':
return <TravelInspector el={el} stats={stats} path={path} set={set} t={t} locale={locale} position={position} />
default:
return <>{position}</>
}
}
@@ -152,6 +152,66 @@ describe('the layers panel', () => {
expect(screen.getAllByText('journey.studio.layers.empty').length).toBeGreaterThan(0); expect(screen.getAllByText('journey.studio.layers.empty').length).toBeGreaterThan(0);
}); });
it('is one stop in the Tab order, and the arrows walk the rows from there', () => {
const { container } = render(<StudioLayersPanel t={t} />);
expect(rows(container).map((r) => r.tabIndex)).toEqual([0, -1, -1, -1, -1]);
fireEvent.keyDown(must(rows(container)[0]), { key: 'ArrowDown' });
expect(document.activeElement).toBe(rows(container)[1]);
expect(rows(container).map((r) => r.tabIndex)).toEqual([-1, 0, -1, -1, -1]);
fireEvent.keyDown(must(rows(container)[1]), { key: 'End' });
expect(document.activeElement).toBe(rows(container)[4]);
fireEvent.keyDown(must(rows(container)[4]), { key: 'ArrowDown' });
expect(document.activeElement).toBe(rows(container)[4]);
fireEvent.keyDown(must(rows(container)[4]), { key: 'Home' });
expect(document.activeElement).toBe(rows(container)[0]);
});
it('selects with Enter or Space, Shift adding, and keeps the arrow off the page', () => {
store().select(['a']);
const { container } = render(<StudioLayersPanel t={t} />);
// The selected row is the one Tab reaches.
expect(must(rows(container)[4]).tabIndex).toBe(0);
fireEvent.keyDown(must(rows(container)[0]), { key: 'Enter' });
expect(store().selection).toEqual(['t']);
fireEvent.keyDown(must(rows(container)[4]), { key: ' ', shiftKey: true });
expect(store().selection).toEqual(['t', 'a']);
// Taken by the tree, so the sheet's own keys (useStudioKeys) leave the selection where it is.
expect(fireEvent.keyDown(must(rows(container)[4]), { key: 'ArrowUp' })).toBe(false);
expect(fireEvent.keyDown(must(rows(container)[3]), { key: 'Delete' })).toBe(true);
});
it('closes a group with Left, opens it with Right, and steps between a member and its group', () => {
const { container } = render(<StudioLayersPanel t={t} />);
fireEvent.keyDown(must(rows(container)[2]), { key: 'ArrowLeft' });
expect(document.activeElement).toBe(rows(container)[1]);
fireEvent.keyDown(must(rows(container)[1]), { key: 'ArrowLeft' });
expect(rows(container).map((r) => r.dataset.layerKey)).toEqual(['t', 'g:g', 'a']);
expect(must(rows(container)[1]).getAttribute('aria-expanded')).toBe('false');
fireEvent.keyDown(must(rows(container)[1]), { key: 'ArrowRight' });
expect(rows(container)).toHaveLength(5);
fireEvent.keyDown(must(rows(container)[1]), { key: 'ArrowRight' });
expect(document.activeElement).toBe(rows(container)[2]);
});
it('renames with F2, and the keyboard is back on the row after Enter or Escape', () => {
const { container } = render(<StudioLayersPanel t={t} />);
fireEvent.keyDown(must(rows(container)[0]), { key: 'F2' });
const input = container.querySelector('.st-layer-input') as HTMLInputElement;
expect(document.activeElement).toBe(input);
fireEvent.change(input, { target: { value: 'Title' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(el('t').name).toBe('Title');
expect(document.activeElement).toBe(rows(container)[0]);
fireEvent.keyDown(must(rows(container)[0]), { key: 'F2' });
const again = container.querySelector('.st-layer-input') as HTMLInputElement;
fireEvent.change(again, { target: { value: 'Something else' } });
fireEvent.keyDown(again, { key: 'Escape' });
expect(el('t').name).toBe('Title');
expect(container.querySelector('.st-layer-input')).toBeNull();
expect(document.activeElement).toBe(rows(container)[0]);
});
it('offers no drag, lock or visibility to a book held read-only', () => { it('offers no drag, lock or visibility to a book held read-only', () => {
useStudioStore.setState({ readOnly: true }); useStudioStore.setState({ readOnly: true });
const { container } = render(<StudioLayersPanel t={t} />); const { container } = render(<StudioLayersPanel t={t} />);
@@ -175,7 +235,8 @@ describe('the align section', () => {
store().select(['a']); store().select(['a']);
render(<StudioArrangeSection spreadIndex={0} t={t} />); render(<StudioArrangeSection spreadIndex={0} t={t} />);
// Folded for one element: lining up is not what one element usually needs next. // Folded for one element: lining up is not what one element usually needs next.
fireEvent.click(screen.getByText('journey.studio.layers.align')); expect(screen.queryByLabelText('journey.studio.layers.alignRight')).toBeNull();
fireEvent.click(screen.getByText('journey.studio.arrange'));
expect(screen.getByRole('radio', { name: 'journey.studio.layers.toSelection' })).toBeDisabled(); expect(screen.getByRole('radio', { name: 'journey.studio.layers.toSelection' })).toBeDisabled();
fireEvent.click(screen.getByLabelText('journey.studio.layers.alignRight')); fireEvent.click(screen.getByLabelText('journey.studio.layers.alignRight'));
expect(el('a').frame.x).toBe(190); expect(el('a').frame.x).toBe(190);
@@ -27,7 +27,17 @@ import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-layers.css'; import '../../styles/studio-layers.css';
import { Tooltip } from '../shared/Tooltip'; import { Tooltip } from '../shared/Tooltip';
import { PanelHead } from './StudioPanelHead'; import { PanelHead } from './StudioPanelHead';
import { dropOn, layerLabel, layerRows, rowIds, type LayerDrop, type LayerRow } from './studioLayerRows'; import {
dropOn,
layerKeyAction,
layerLabel,
layerRows,
rowIds,
type LayerDrop,
type LayerKeyAction,
type LayerRow,
} from './studioLayerRows';
import { runsRtl } from './studioRoving';
import { useStudioShortcut } from './studioShortcut'; import { useStudioShortcut } from './studioShortcut';
/** /**
@@ -41,6 +51,12 @@ import { useStudioShortcut } from './studioShortcut';
* sit at the end of every row, always shown when they are on so a locked or * sit at the end of every row, always shown when they are on so a locked or
* hidden layer can be told at a glance; a hidden layer is dimmed, not removed, * hidden layer can be told at a glance; a hidden layer is dimmed, not removed,
* since this panel is the way back to it. Every change is one undo step. * since this panel is the way back to it. Every change is one undo step.
*
* The tree is one stop in the Tab order, the row last focused or else the
* selected one, and its keys are a tree view's (studioLayerRows.ts): the arrows
* move along it and open or close a group, Enter or Space selects a row (with
* Shift added), F2 renames it. A hidden or locked layer, which cannot be
* clicked on the page, is reached this way too.
*/ */
const KIND_ICONS: Record<string, LucideIcon> = { const KIND_ICONS: Record<string, LucideIcon> = {
@@ -90,6 +106,8 @@ export function StudioLayersPanel({ t }: { t: T }) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set()); const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const [renaming, setRenaming] = useState<string | null>(null); const [renaming, setRenaming] = useState<string | null>(null);
const [drag, setDrag] = useState<Drag | null>(null); const [drag, setDrag] = useState<Drag | null>(null);
/** The row the keyboard is on, so the tree keeps its place in the Tab order. */
const [focusKey, setFocusKey] = useState<string | null>(null);
const list = useRef<HTMLDivElement>(null); const list = useRef<HTMLDivElement>(null);
/** A drag ends in a click on the row it started from; that click is not a choice. */ /** A drag ends in a click on the row it started from; that click is not a choice. */
const justDragged = useRef(false); const justDragged = useRef(false);
@@ -114,14 +132,55 @@ export function StudioLayersPanel({ t }: { t: T }) {
return next; return next;
}); });
const selectRow = (row: LayerRow, additive: boolean) => {
if (row.type === 'group') selectGroup(spreadIndex, row.groupId, additive);
else if (row.inGroup && !additive) enterGroup(spreadIndex, row.el.id);
else pick(spreadIndex, row.el.id, additive);
};
const choose = (row: LayerRow, additive: boolean) => { const choose = (row: LayerRow, additive: boolean) => {
if (justDragged.current) { if (justDragged.current) {
justDragged.current = false; justDragged.current = false;
return; return;
} }
if (row.type === 'group') selectGroup(spreadIndex, row.groupId, additive); selectRow(row, additive);
else if (row.inGroup && !additive) enterGroup(spreadIndex, row.el.id); };
else pick(spreadIndex, row.el.id, additive);
/** The one row in the Tab order: the one last focused, else the first selected, else the front one. */
const tabKey =
rows.find((r) => r.key === focusKey)?.key ??
rows.find((r) => rowIds(r).every((id) => selected.has(id)))?.key ??
rows[0]?.key;
const focusRow = (row: LayerRow | undefined) => {
if (!row) return;
setFocusKey(row.key);
const nodes = list.current?.querySelectorAll<HTMLElement>('[data-layer-key]') ?? [];
Array.from(nodes)
.find((n) => n.dataset.layerKey === row.key)
?.focus();
};
const runKey = (action: LayerKeyAction, row: LayerRow) => {
if (action.type === 'focus') focusRow(rows[action.index]);
else if (action.type === 'expand' || action.type === 'collapse') toggleCollapsed(action.groupId);
else if (action.type === 'rename') {
if (!readOnly) setRenaming(row.key);
} else selectRow(row, action.additive);
};
const onTreeKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const node = e.target as HTMLElement;
// The caret, the toggles and the name field keep their own keys.
const index = rows.findIndex((r) => r.key === node.dataset.layerKey);
const row = rows[index];
if (!row) return;
const additive = e.shiftKey || e.metaKey || e.ctrlKey;
const action = layerKeyAction(e.key, index, rows, { collapsed, rtl: runsRtl(node), additive });
if (!action) return;
// Taken here, so the page does not also nudge or act on the selection (useStudioKeys.ts).
e.preventDefault();
runKey(action, row);
}; };
/** What a drag from this row carries: the selection when the row is in it, else what the row stands for. */ /** What a drag from this row carries: the selection when the row is in it, else what the row stands for. */
@@ -203,6 +262,7 @@ export function StudioLayersPanel({ t }: { t: T }) {
className={`st-layers ${drag?.active ? 'is-dragging' : ''}`} className={`st-layers ${drag?.active ? 'is-dragging' : ''}`}
role="tree" role="tree"
aria-label={t('journey.studio.layers')} aria-label={t('journey.studio.layers')}
onKeyDown={onTreeKey}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onPointerCancel={() => setDrag(null)} onPointerCancel={() => setDrag(null)}
@@ -219,6 +279,8 @@ export function StudioLayersPanel({ t }: { t: T }) {
dragged={!!drag?.active && rowIds(row).every((id) => drag.ids.includes(id))} dragged={!!drag?.active && rowIds(row).every((id) => drag.ids.includes(id))}
dropAt={drag?.active && drag.overKey === row.key ? (drag.upper ? 'above' : 'below') : null} dropAt={drag?.active && drag.overKey === row.key ? (drag.upper ? 'above' : 'below') : null}
readOnly={readOnly} readOnly={readOnly}
tabStop={row.key === tabKey}
onFocusRow={() => setFocusKey(row.key)}
onPointerDown={(e) => onPointerDown(e, row)} onPointerDown={(e) => onPointerDown(e, row)}
onChoose={(additive) => choose(row, additive)} onChoose={(additive) => choose(row, additive)}
onToggleCollapsed={() => row.type === 'group' && toggleCollapsed(row.groupId)} onToggleCollapsed={() => row.type === 'group' && toggleCollapsed(row.groupId)}
@@ -263,6 +325,9 @@ interface RowProps {
dragged: boolean; dragged: boolean;
dropAt: 'above' | 'below' | null; dropAt: 'above' | 'below' | null;
readOnly: boolean; readOnly: boolean;
/** The row the Tab key reaches the tree on. */
tabStop: boolean;
onFocusRow: () => void;
onPointerDown: (e: React.PointerEvent) => void; onPointerDown: (e: React.PointerEvent) => void;
onChoose: (additive: boolean) => void; onChoose: (additive: boolean) => void;
onToggleCollapsed: () => void; onToggleCollapsed: () => void;
@@ -312,8 +377,11 @@ function LayerRowView(p: RowProps) {
className={rowClasses(p, hidden)} className={rowClasses(p, hidden)}
data-layer-key={row.key} data-layer-key={row.key}
role="treeitem" role="treeitem"
tabIndex={p.tabStop ? 0 : -1}
aria-level={row.type === 'element' && row.inGroup ? 2 : 1}
aria-selected={p.selected} aria-selected={p.selected}
aria-expanded={row.type === 'group' ? !p.collapsed : undefined} aria-expanded={row.type === 'group' ? !p.collapsed : undefined}
onFocus={p.onFocusRow}
onPointerDown={p.onPointerDown} onPointerDown={p.onPointerDown}
onClick={(e) => p.onChoose(e.shiftKey || e.metaKey || e.ctrlKey)} onClick={(e) => p.onChoose(e.shiftKey || e.metaKey || e.ctrlKey)}
> >
@@ -363,11 +431,14 @@ function RowName(p: {
onCommit: (value: string) => void; onCommit: (value: string) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
// Escape hands the focus back to the row, which blurs the field: that blur keeps nothing.
const cancelled = useRef(false);
if (!p.renaming) { if (!p.renaming) {
return ( return (
<span <span
className={`st-layer-name ${p.named ? '' : 'is-auto'}`} className={`st-layer-name ${p.named ? '' : 'is-auto'}`}
title={p.t('journey.studio.layers.rename')} // One line is a must in a tree of rows, so a long name is cut there and read whole here.
title={p.label}
onDoubleClick={own(p.onRename)} onDoubleClick={own(p.onRename)}
> >
{p.label} {p.label}
@@ -383,14 +454,28 @@ function RowName(p: {
maxLength={60} maxLength={60}
aria-label={p.t('journey.studio.layers.rename')} aria-label={p.t('journey.studio.layers.rename')}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onBlur={(e) => p.onCommit(e.target.value)} onFocus={() => {
cancelled.current = false;
}}
onBlur={(e) => {
if (!cancelled.current) p.onCommit(e.target.value);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); // Back to the row either way, so the keyboard keeps its place in the tree.
const row = e.currentTarget.closest<HTMLElement>('[data-layer-key]');
if (e.key === 'Enter') {
// Moving the focus blurs the field, which keeps the name.
if (row) row.focus();
else e.currentTarget.blur();
return;
}
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
// Escape here leaves the name as it was, and must not close Studio. // Escape here leaves the name as it was, and must not close Studio.
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
cancelled.current = true;
p.onCancel(); p.onCancel();
row?.focus();
}} }}
/> />
); );
@@ -7,17 +7,21 @@ import {
bookLineStyled, bookLineStyled,
} from '@trek/shared'; } from '@trek/shared';
import { ArrowLeftRight } from 'lucide-react'; import { ArrowLeftRight } from 'lucide-react';
import type { ReactNode } from 'react';
import { useStudioStore } from '../../store/studioStore'; import { useStudioStore } from '../../store/studioStore';
import { ornamentInUse } from './inspector/inspectorModel';
import { lineGeometry } from './lineRender';
import { LinePaths } from './LineView'; import { LinePaths } from './LineView';
import { Choice, Line, NumField, Section } from './StudioControls'; import { Choice, Line, NumField, Section } from './StudioControls';
import { STROKE_STYLE_OPTIONS } from './studioOptions';
import { Swatches } from './StudioSwatches'; import { Swatches } from './StudioSwatches';
import { lineGeometry } from './lineRender';
/** /**
* The inspector of a drawn line: its colour and weight, dashes, the course it * The inspector of a drawn line: the course it takes first (straight or
* takes, a wave or a zigzag, the hand-drawn wobble, what sits on each end and * smooth, a wave or a zigzag, the hand-drawn wobble), then where it sits, then
* in the middle. * its look (colour, weight, dashes) and what sits on each end, and folded
* under those the ornament in its middle, open on a line that has one.
* *
* Every change goes through `bookLineStyled`, the book engine's own restyle, * Every change goes through `bookLineStyled`, the book engine's own restyle,
* so a wider stroke or a new head refits the frame exactly as the assistant's * so a wider stroke or a new head refits the frame exactly as the assistant's
@@ -66,10 +70,13 @@ const OrnamentGlyph = ({ ornament }: { ornament: BookLineOrnament }) => (
export function LineInspector({ export function LineInspector({
el, el,
spreadIndex, spreadIndex,
position,
t, t,
}: { }: {
el: BookLineElement; el: BookLineElement;
spreadIndex: number; spreadIndex: number;
/** The position and size group, which every kind places after what it is. */
position: ReactNode;
t: (k: string) => string; t: (k: string) => string;
}) { }) {
const commit = useStudioStore((s) => s.commit); const commit = useStudioStore((s) => s.commit);
@@ -119,19 +126,29 @@ export function LineInspector({
return ( return (
<> <>
<Section label={t('journey.studio.kind.line')}> <Section label={t('journey.studio.kind.line')}>
<Line label={t('journey.studio.stroke')} wide> <Line label={t('journey.studio.lineCourseTitle')} wide>
<Swatches value={el.stroke} onPick={(c) => set({ stroke: c })} /> <Choice
</Line> value={el.curve}
<div className="st-grid2" style={{ marginTop: 10 }}> ariaLabel={t('journey.studio.lineCourseTitle')}
<NumField options={(['straight', 'smooth'] as const).map((value) => ({
label={t('journey.studio.strokeWidth')} value,
value={el.strokeWidth} label: t(`journey.studio.lineCourse.${value}`),
min={0.05} }))}
max={20} onPick={(curve) => set({ curve })}
step={0.05}
unit="mm"
onChange={(v) => set({ strokeWidth: v })}
/> />
</Line>
<Line label={t('journey.studio.lineBodyTitle')} wide>
<Choice
value={el.squiggle}
ariaLabel={t('journey.studio.lineBodyTitle')}
options={(['none', 'wave', 'zigzag'] as const).map((value) => ({
value,
label: t(`journey.studio.lineBody.${value}`),
}))}
onPick={(squiggle) => set({ squiggle })}
/>
</Line>
<div className="st-section-gap">
<NumField <NumField
label={t('journey.studio.wobble')} label={t('journey.studio.wobble')}
value={Math.round(el.wobble * 100)} value={Math.round(el.wobble * 100)}
@@ -142,42 +159,34 @@ export function LineInspector({
onChange={(v) => set({ wobble: Math.min(1, Math.max(0, v / 100)) })} onChange={(v) => set({ wobble: Math.min(1, Math.max(0, v / 100)) })}
/> />
</div> </div>
<div style={{ marginTop: 10 }}> <p className="st-hint st-hint-after">{t('journey.studio.linePointsHint')}</p>
<Line label={t('journey.studio.strokeStyle')} wide> </Section>
{position}
<Section label={t('journey.studio.look')}>
<Swatches value={el.stroke} onPick={(c) => set({ stroke: c })} />
<div className="st-section-gap">
<NumField
label={t('journey.studio.strokeWidth')}
value={el.strokeWidth}
min={0.05}
max={20}
step={0.05}
unit="mm"
onChange={(v) => set({ strokeWidth: v })}
/>
</div>
<div className="st-section-gap">
<Line label={t('journey.studio.strokeStyle')}>
<Choice <Choice
value={el.dash} value={el.dash}
options={[ ariaLabel={t('journey.studio.strokeStyle')}
{ value: 'solid' as const, label: t('journey.studio.strokeSolid') }, options={STROKE_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
{ value: 'dashed' as const, label: t('journey.studio.strokeDashed') },
{ value: 'dotted' as const, label: t('journey.studio.strokeDotted') },
]}
onPick={(dash) => set({ dash })} onPick={(dash) => set({ dash })}
/> />
</Line> </Line>
<Line label={t('journey.studio.lineCourseTitle')} wide>
<Choice
value={el.curve}
options={(['straight', 'smooth'] as const).map((value) => ({
value,
label: t(`journey.studio.lineCourse.${value}`),
}))}
onPick={(curve) => set({ curve })}
/>
</Line>
<Line label={t('journey.studio.lineBodyTitle')} wide>
<Choice
value={el.squiggle}
options={(['none', 'wave', 'zigzag'] as const).map((value) => ({
value,
label: t(`journey.studio.lineBody.${value}`),
}))}
onPick={(squiggle) => set({ squiggle })}
/>
</Line>
</div> </div>
<p className="st-hint" style={{ paddingTop: 8 }}>
{t('journey.studio.linePointsHint')}
</p>
</Section> </Section>
<Section label={t('journey.studio.lineEnds')}> <Section label={t('journey.studio.lineEnds')}>
@@ -187,7 +196,7 @@ export function LineInspector({
<Line label={t('journey.studio.lineEnd')} wide> <Line label={t('journey.studio.lineEnd')} wide>
<Choice value={el.endCap} options={caps(false)} onPick={(endCap) => set({ endCap })} /> <Choice value={el.endCap} options={caps(false)} onPick={(endCap) => set({ endCap })} />
</Line> </Line>
<div className="st-row" style={{ marginTop: 6 }}> <div className="st-row st-section-gap">
<button type="button" className="st-chip" onClick={swapEnds}> <button type="button" className="st-chip" onClick={swapEnds}>
<ArrowLeftRight size={13} /> <ArrowLeftRight size={13} />
{t('journey.studio.swapEnds')} {t('journey.studio.swapEnds')}
@@ -195,7 +204,7 @@ export function LineInspector({
</div> </div>
</Section> </Section>
<Section label={t('journey.studio.ornamentTitle')} defaultOpen={el.ornament !== 'none'}> <Section label={t('journey.studio.ornamentTitle')} defaultOpen={ornamentInUse(el)}>
<Choice <Choice
value={el.ornament} value={el.ornament}
options={BOOK_LINE_ORNAMENTS.map((o) => ({ options={BOOK_LINE_ORNAMENTS.map((o) => ({
@@ -1,6 +1,8 @@
import { type BookElement, type BookPageSetup, bookElementId, bookNewLine } from '@trek/shared'; import { type BookElement, type BookPageSetup, bookElementId, bookNewLine } from '@trek/shared';
import type { ReactNode } from 'react';
import { LinePaths } from './LineView'; import { LinePaths } from './LineView';
import { Shelf } from './StudioPanelHead';
import { DIVIDER_PRESETS, LINE_PRESETS, type LinePreset as Preset, presetPoints } from './linePresets'; import { DIVIDER_PRESETS, LINE_PRESETS, type LinePreset as Preset, presetPoints } from './linePresets';
import { lineGeometry } from './lineRender'; import { lineGeometry } from './lineRender';
@@ -39,11 +41,14 @@ export function StudioLineShelf({
single, single,
onAdd, onAdd,
t, t,
lead,
}: { }: {
page: BookPageSetup; page: BookPageSetup;
single: boolean; single: boolean;
onAdd: (el: BookElement) => void; onAdd: (el: BookElement) => void;
t: (k: string) => string; t: (k: string) => string;
/** A tile before the drawn lines: the Elements panel puts its plain rule there. */
lead?: ReactNode;
}) { }) {
const add = (preset: Preset) => { const add = (preset: Preset) => {
const origin = single ? 0 : page.pageWidth; const origin = single ? 0 : page.pageWidth;
@@ -52,8 +57,9 @@ export function StudioLineShelf({
const points = presetPoints(preset, x, page.pageHeight / 2, w); const points = presetPoints(preset, x, page.pageHeight / 2, w);
onAdd(bookNewLine({ ...preset.look, points }, { id: bookElementId('l'), frame: null, inner: !single, page })); onAdd(bookNewLine({ ...preset.look, points }, { id: bookElementId('l'), frame: null, inner: !single, page }));
}; };
const tiles = (list: readonly Preset[]) => ( const tiles = (list: readonly Preset[], first?: ReactNode) => (
<div className="st-shape-grid"> <div className="st-shape-grid">
{first}
{list.map((p) => { {list.map((p) => {
const label = t(`journey.studio.linePreset.${p.id}`); const label = t(`journey.studio.linePreset.${p.id}`);
return ( return (
@@ -73,14 +79,8 @@ export function StudioLineShelf({
); );
return ( return (
<> <>
<div className="st-section"> <Shelf label={t('journey.studio.drawnLines')}>{tiles(LINE_PRESETS, lead)}</Shelf>
<div className="st-section-label">{t('journey.studio.drawnLines')}</div> <Shelf label={t('journey.studio.dividers')}>{tiles(DIVIDER_PRESETS)}</Shelf>
{tiles(LINE_PRESETS)}
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.dividers')}</div>
{tiles(DIVIDER_PRESETS)}
</div>
</> </>
); );
} }
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { PanelHead, Shelf } from './StudioPanelHead';
/**
* FE-STUDIO-HEAD-001 to FE-STUDIO-HEAD-003: the one head every Studio panel
* opens on, and the shelf the panels on the left group their catalogues in.
*/
describe('the panel head', () => {
it('FE-STUDIO-HEAD-001: names the panel as a heading, with its count, a fact and its actions', () => {
const { container } = render(
<PanelHead label="Pages" count={7} meta="210 × 210 mm" actions={<button type="button">Act</button>} />
);
const heading = screen.getByRole('heading', { level: 2 });
expect(heading.textContent).toBe('Pages7');
expect(container.querySelector('.st-what-label')?.textContent).toBe('Pages');
expect(container.querySelector('.st-what em')?.textContent).toBe('7');
expect(container.querySelector('.st-head-meta')?.textContent).toBe('210 × 210 mm');
expect(container.querySelector('.st-head-acts button')?.textContent).toBe('Act');
});
it('FE-STUDIO-HEAD-002: leaves out what it is not given, and keeps a long name in a label that wraps', () => {
const long = 'Gruppierung aufheben und Ausrichtung zurücksetzen';
const { container } = render(<PanelHead label={long} />);
expect(container.querySelector('.st-what em')).toBeNull();
expect(container.querySelector('.st-head-meta')).toBeNull();
expect(container.querySelector('.st-head-acts')).toBeNull();
expect(container.querySelector('.st-what-label')?.textContent).toBe(long);
});
});
describe('the shelf', () => {
it('FE-STUDIO-HEAD-003: heads its things with a label and closes with a hint, or holds them bare', () => {
const { container, rerender } = render(
<Shelf label="Stars" hint="Click one to add it.">
<button type="button">Star</button>
</Shelf>
);
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Stars');
expect(container.querySelector('.st-shelf > .st-shelf-hint')?.textContent).toBe('Click one to add it.');
rerender(
<Shelf>
<button type="button">Star</button>
</Shelf>
);
expect(screen.queryByRole('heading')).toBeNull();
expect(container.querySelector('.st-shelf-hint')).toBeNull();
expect(screen.getByRole('button', { name: 'Star' })).toBeTruthy();
});
});
@@ -1,15 +1,59 @@
import type { ReactNode } from 'react'
/** /**
* The bar at the top of a Studio panel: what it is, and how many. * The bar at the top of every Studio panel, on both sides of the page: what the
* panel is about, how many of it there are, and the things you do to it.
* *
* Its own file so the three panels can share it without any of them having to * One head for all of them. The panels on the left used to open on small grey
* import the others — `StudioSidebar` owns the rail, and a panel that reached * capitals and the inspector on a darker title of its own, so the two sides of
* back into it for a header would make the sidebar impossible to split. * the screen had two ideas of what a panel's top looks like, and the left ones
* had none at all: their head was set exactly like every group label under it.
* Now each panel opens on its name in ink, the count beside it as a quiet
* figure, and the actions at the end. The head keeps one height whether it has
* actions or not, so the heads of the two sides line up across the page.
*
* Its own file so every panel can share it without importing another panel:
* `StudioSidebar` owns the rail, and a panel that reached back into it for a
* header would make the sidebar impossible to split.
*/ */
export function PanelHead({ label, count }: { label: string; count?: number }) { export function PanelHead({ label, count, meta, actions }: {
label: string
count?: number
/** A fact about the panel's subject in quiet type after the name, such as the page size. */
meta?: string
actions?: ReactNode
}) {
return ( return (
<div className="st-panel-head"> <div className="st-panel-head">
<span>{label}</span> <h2 className="st-what">
{count != null && <span style={{ fontVariantNumeric: 'tabular-nums' }}>{count}</span>} <span className="st-what-label">{label}</span>
{count != null && <em>{count}</em>}
</h2>
{meta && <span className="st-head-meta">{meta}</span>}
{actions && <span className="st-head-acts">{actions}</span>}
</div>
)
}
/**
* A titled group of things to add, in the panels on the left: a heading over a
* grid or a list, and a line of help under it when it needs one.
*
* Never folded. A shelf is browsed, and a library whose groups had to be opened
* one at a time would be a library read through a keyhole; the inspector's
* groups fold because they are a reference, these are a catalogue.
*/
export function Shelf({ label, hint, children, className = '' }: {
label?: string
hint?: string
children: ReactNode
className?: string
}) {
return (
<div className={`st-section st-shelf ${className}`}>
{label && <h3 className="st-section-label">{label}</h3>}
{children}
{hint && <p className="st-hint st-shelf-hint">{hint}</p>}
</div> </div>
) )
} }
@@ -0,0 +1,46 @@
import { Search, X } from 'lucide-react';
/**
* The search field of a Studio panel: the photos and entries of the journey,
* the icon library, the stickers. One field, so the three look and behave
* alike, the clear button included. It sits above the list it searches rather
* than inside it, so it stays in reach while the list scrolls.
*
* A library with no tab of its own to count on (the icons, the stickers) gives
* the field its count, and the field shows it while something is typed: how
* many match is the first thing a search answers.
*/
export function StudioSearch({
value,
onChange,
label,
clearLabel,
count,
}: {
value: string;
onChange: (value: string) => void;
/** The placeholder and the field's accessible name. */
label: string;
clearLabel: string;
/** How many the words match, shown beside them while there are any. */
count?: number;
}) {
return (
<div className="st-search">
<Search size={14} aria-hidden />
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={label}
aria-label={label}
spellCheck={false}
/>
{value.trim() !== '' && count != null && <output className="st-search-count">{count}</output>}
{value && (
<button type="button" onClick={() => onChange('')} aria-label={clearLabel}>
<X size={13} />
</button>
)}
</div>
);
}
@@ -6,8 +6,10 @@ import {
type BookShapeElement, type BookShapeElement,
type BookTheme, type BookTheme,
} from '@trek/shared'; } from '@trek/shared';
import { shapeOutlineInUse } from './inspector/inspectorModel';
import { usePaintChannel } from './paint/usePaintChannel'; import { usePaintChannel } from './paint/usePaintChannel';
import { Line, Section } from './StudioControls'; import { Choice, Line, NumField, Section } from './StudioControls';
import { STROKE_STYLE_OPTIONS } from './studioOptions';
import { StudioPaintPicker } from './StudioPaintPicker'; import { StudioPaintPicker } from './StudioPaintPicker';
import type { StudioT } from './StudioSidebar'; import type { StudioT } from './StudioSidebar';
import { Swatches } from './StudioSwatches'; import { Swatches } from './StudioSwatches';
@@ -16,19 +18,20 @@ import { StudioThemeLink } from './theme/StudioThemeLink';
/** The outline a shape is given when its fill goes and it has none, so it never turns invisible. */ /** The outline a shape is given when its fill goes and it has none, so it never turns invisible. */
const OUTLINE = '#141414'; const OUTLINE = '#141414';
type SetShape = (patch: Partial<BookShapeElement>) => void;
/** /**
* A shape's colours: its fill as a paint, and its outline. * A shape's fill, as a paint: none, one colour, a gradient or a pattern.
* *
* The fill goes to every selected shape at once, the way the rest of the * The fill goes to every selected shape at once, the way the rest of the
* inspector's properties do. Taking the fill away from a shape with no * inspector's properties do. Taking the fill away from a shape with no
* outline gives it one, as the old fill switch did: a shape with neither is * outline gives it one, as the old fill switch did: a shape with neither is
* a shape nobody can see or find again. * a shape nobody can see or find again.
*/ */
export function StudioShapePaint({ export function StudioShapeFill({
el, el,
spreadIndex, spreadIndex,
selection, selection,
onStroke,
theme, theme,
set, set,
t, t,
@@ -36,11 +39,10 @@ export function StudioShapePaint({
el: BookShapeElement; el: BookShapeElement;
spreadIndex: number; spreadIndex: number;
selection: readonly string[]; selection: readonly string[];
onStroke: (color: string) => void; /** The book's theme, for the theme colours under the paint; none without one. */
/** The book's theme, for the theme colours under each row; none without one. */
theme?: BookTheme; theme?: BookTheme;
/** The inspector's write, for linking a colour to the theme. */ /** The inspector's write, for linking the fill to the theme. */
set?: (patch: Partial<BookShapeElement>) => void; set: SetShape;
t: StudioT; t: StudioT;
}) { }) {
const channel = usePaintChannel((paint) => (doc) => ({ const channel = usePaintChannel((paint) => (doc) => ({
@@ -63,31 +65,75 @@ export function StudioShapePaint({
})); }));
return ( return (
<Section label={t('journey.studio.colour')}> <Section label={t('journey.studio.fill')}>
<Line label={t('journey.studio.fill')} wide> <StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} />
<StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} /> <StudioThemeLink
{set && ( theme={theme}
<StudioThemeLink token={el.token}
theme={theme} t={t}
token={el.token} onLink={(token, fill) => set({ fill, token, paint: undefined })}
t={t} onUnlink={() => set({ token: undefined })}
onLink={(token, fill) => set({ fill, token, paint: undefined })} />
onUnlink={() => set({ token: undefined })} </Section>
/> );
)} }
</Line>
<Line label={t('journey.studio.stroke')} wide> /**
<Swatches value={el.stroke ?? OUTLINE} onPick={onStroke} /> * A shape's outline: its colour, the stroke it is drawn in and how wide.
{set && ( *
<StudioThemeLink * Folded until the shape has one, since most shapes are a fill and nothing
theme={theme} * else. Every way of asking for an outline brings one: a colour, a width or a
token={el.strokeToken} * dash on a shape that had none draws it rather than doing nothing.
t={t} */
onLink={(strokeToken, stroke) => set({ stroke, strokeToken, strokeWidth: el.strokeWidth || 0.5 })} export function StudioShapeOutline({
onUnlink={() => set({ strokeToken: undefined })} el,
/> theme,
)} set,
</Line> t,
}: {
el: BookShapeElement;
theme?: BookTheme;
set: SetShape;
t: StudioT;
}) {
const width = el.strokeWidth || 0.5;
return (
<Section label={t('journey.studio.stroke')} defaultOpen={shapeOutlineInUse(el)}>
<Swatches value={el.stroke ?? OUTLINE} onPick={(stroke) => set({ stroke, strokeWidth: width })} />
<StudioThemeLink
theme={theme}
token={el.strokeToken}
t={t}
onLink={(strokeToken, stroke) => set({ stroke, strokeToken, strokeWidth: width })}
onUnlink={() => set({ strokeToken: undefined })}
/>
<div className="st-section-gap">
<Line label={t('journey.studio.strokeStyle')}>
<Choice
value={el.strokeStyle}
ariaLabel={t('journey.studio.strokeStyle')}
options={STROKE_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
onPick={(strokeStyle) =>
set({
strokeStyle,
// A stroke style with no stroke is invisible, so asking for dashes turns the outline on.
...(el.stroke ? {} : { stroke: el.fill ?? OUTLINE, strokeWidth: width }),
})
}
/>
</Line>
</div>
<div className="st-section-gap">
<NumField
label={t('journey.studio.strokeWidth')}
value={el.strokeWidth}
min={0}
max={20}
step={0.1}
unit="mm"
onChange={(v) => set({ strokeWidth: v, ...(v > 0 && !el.stroke ? { stroke: OUTLINE } : {}) })}
/>
</div>
</Section> </Section>
); );
} }
@@ -9,6 +9,7 @@ import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets' import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
import { foliosOf } from './bookSheets' import { foliosOf } from './bookSheets'
import { StudioSidebar } from './StudioSidebar' import { StudioSidebar } from './StudioSidebar'
import type { RailSection } from './sidebar/railSections'
import { ProviderPicker } from '../Journey/JourneyDetailPageProviderPicker' import { ProviderPicker } from '../Journey/JourneyDetailPageProviderPicker'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { StudioCanvas } from './StudioCanvas' import { StudioCanvas } from './StudioCanvas'
@@ -46,6 +47,8 @@ export default function StudioShell() {
const navigate = useNavigate() const navigate = useNavigate()
const [bookView, setBookView] = useState(true) const [bookView, setBookView] = useState(true)
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false)
// The rail's open section lives here, so the inspector can open the Theme or the Layers panel beside it.
const [railSection, setRailSection] = useState<RailSection>('pages')
const assistant = useStudioAssistant({ journeyId: s.journeyId, flush: s.flushBook }) const assistant = useStudioAssistant({ journeyId: s.journeyId, flush: s.flushBook })
// The book's faces into the offline cache as it opens (bookFontWarm.ts). // The book's faces into the offline cache as it opens (bookFontWarm.ts).
useWarmBookFaces() useWarmBookFaces()
@@ -126,6 +129,8 @@ export default function StudioShell() {
onToggleStop={s.setStopExcluded} onToggleStop={s.setStopExcluded}
providers={s.photoProviders} providers={s.photoProviders}
onBrowseProvider={s.browseProvider} onBrowseProvider={s.browseProvider}
section={railSection}
onSection={setRailSection}
/> />
<Workbench s={s} bookView={bookView} /> <Workbench s={s} bookView={bookView} />
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}> <StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
@@ -138,6 +143,7 @@ export default function StudioShell() {
setPageNumbers={s.setPageNumbers} setPageNumbers={s.setPageNumbers}
t={s.t} t={s.t}
locale={s.locale} locale={s.locale}
onOpenPanel={setRailSection}
/> />
</StudioPaletteScope> </StudioPaletteScope>
</div> </div>
+51 -796
View File
@@ -1,50 +1,39 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useState } from 'react'
import { import type { BookPageSetup, JourneyStats } from '@trek/shared'
Camera, ChevronDown, ImagePlus, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, Layers, LayoutTemplate,
Palette, Plus, Search, Shapes, Sticker, Trash2, Upload, X,
} from 'lucide-react'
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
import { useElementSize } from '../../hooks/useElementSize'
import { useStudioStore } from '../../store/studioStore'
import { formatDate } from '../../utils/formatters'
import { useToast } from '../shared/Toast'
import { SpreadThumb } from './SpreadThumb'
import { folioLabel, foliosOf } from './bookSheets'
import { photoSrc } from './bookRender'
import {
BOOK_COVER_TEMPLATES as COVER_TEMPLATES, BOOK_TEMPLATES as TEMPLATES, applyBookTemplate as applyTemplate,
bookElementId as uid,
} from '@trek/shared'
import {
bookCoordValue as coordValue, formatBookCoords, formatBookDate, type BookCoordFormat as CoordFormat,
} from '@trek/shared'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { PanelHead as Head } from './StudioPanelHead'
import { StudioElementsPanel } from './StudioElementsPanel' import { StudioElementsPanel } from './StudioElementsPanel'
import { StudioStickersPanel } from './StudioStickersPanel'
import { StudioLayersPanel } from './StudioLayersPanel' import { StudioLayersPanel } from './StudioLayersPanel'
import { StudioTravelPanel } from './StudioTravelPanel' import { StudioTravelPanel } from './StudioTravelPanel'
import { StudioThemePanel } from './theme/StudioThemePanel' import { StudioThemePanel } from './theme/StudioThemePanel'
import { StudioSavedLayouts } from './StudioSavedLayouts' import { StudioTemplatesPanel } from './templates/StudioTemplatesPanel'
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile' import { StudioRail } from './sidebar/StudioRail'
import { StudioContentFilter } from './StudioContentFilter' import { StudioPagesPanel } from './sidebar/StudioPagesPanel'
import { emptyKeyFor, matchingEntries, matchingPhotos, photosFor, type PhotoFilter } from './photoFilter' import { StudioContentPanel } from './sidebar/StudioContentPanel'
import { RAIL_IDS, type RailSection } from './sidebar/railSections'
import type { StudioUploader } from './studioUpload' import type { StudioUploader } from './studioUpload'
/** /**
* The left side of Studio: a narrow rail of sections and one wide panel showing * The left side of Studio: a narrow rail of sections and one wide panel showing
* whichever is open. * whichever is open.
* *
* The shape is Canva's and it is the right one here for a plain reason — a book * The shape is Canva's and it is the right one here for a plain reason: a book
* has four completely different kinds of thing you reach for (which page, what * has a handful of completely different kinds of thing you reach for (which
* from the journey, what to draw, what arrangement), and putting them behind * page, what from the journey, what arrangement, what to draw), and putting
* tabs in a single 200px column would make each of them cramped. An icon rail * them behind tabs in a single 200px column would make each of them cramped.
* costs 52px and gives the panel the room a photo browser actually needs. * An icon rail costs 64px and gives the panel the room a photo browser needs.
*
* Which section is open is the shell's when it says so (`section` and
* `onSection`), so the inspector on the other side can open the Theme or the
* Layers panel; on its own the sidebar keeps it itself and opens on Pages.
*/ */
type Section = 'pages' | 'content' | 'elements' | 'stickers' | 'travel' | 'templates' | 'layers' | 'theme'
export interface JourneySource { export interface JourneySource {
/**
* The journey's title and subtitle, which a cover is set in when the book has
* no title of its own. The Studio always gives them; a source built only for
* the content browser may leave them out.
*/
title?: string
subtitle?: string | null
entries: { entries: {
id: number id: number
title: string | null title: string | null
@@ -70,29 +59,9 @@ export interface JourneySource {
/** The translator, with the parameters some of the strings here take. */ /** The translator, with the parameters some of the strings here take. */
export type StudioT = (k: string, params?: Record<string, string | number>) => string export type StudioT = (k: string, params?: Record<string, string | number>) => string
/**
* How wide a page preview may be drawn.
*
* Page thumbnails and layout cards are drawn at a `scale()` rather than laid
* out by CSS — the sheet inside them is sized in millimetres — so they need an
* actual pixel width, and it is **measured** rather than derived.
*
* Deriving it was tried twice and was wrong twice. The panel width, the scroll
* padding and the button's own border and padding are all knowable from
* studio.css; the scrollbar is not. It appears only once the list is long
* enough, takes a width that depends on the platform, and the arithmetic that
* ignored it produced a card overhanging its own selection outline. Measuring
* the row the cards sit in gets all four contributions at once and cannot drift
* when any of them changes.
*/
const MIN_PREVIEW_W = 120
/** The button's border and padding, which the row's width still includes. */
const THUMB_CHROME = (2 + 3) * 2
export function StudioSidebar({ export function StudioSidebar({
page, pxPerMm, bookView, source, stats, path, t, locale, page, pxPerMm, bookView, source, stats, path, t, locale,
canEdit, onUpload, onToggleStop, providers = [], onBrowseProvider, canEdit, onUpload, onToggleStop, providers = [], onBrowseProvider, section, onSection,
}: { }: {
page: BookPageSetup page: BookPageSetup
pxPerMm: number pxPerMm: number
@@ -114,46 +83,38 @@ export function StudioSidebar({
providers?: { id: string; name: string }[] providers?: { id: string; name: string }[]
/** Opens the provider picker; answers the photo ids that landed, none when closed. */ /** Opens the provider picker; answers the photo ids that landed, none when closed. */
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]> onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>
/** The open section, when the shell holds it. */
section?: RailSection
onSection?: (section: RailSection) => void
}) { }) {
const [section, setSection] = useState<Section>('pages') const [own, setOwn] = useState<RailSection>('pages')
const open = section ?? own
const SECTIONS: { id: Section; icon: typeof Files; labelKey: string }[] = [ const setOpen = (next: RailSection) => {
{ id: 'pages', icon: Files, labelKey: 'journey.studio.pages' }, setOwn(next)
{ id: 'content', icon: ImageIcon, labelKey: 'journey.studio.content' }, onSection?.(next)
{ id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' }, }
{ id: 'stickers', icon: Sticker, labelKey: 'journey.studio.stickers' },
{ id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' },
{ id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' },
{ id: 'layers', icon: Layers, labelKey: 'journey.studio.layers' },
{ id: 'theme', icon: Palette, labelKey: 'studio.theme.panel' },
]
return ( return (
<> <>
<nav className="st-rail" aria-label={t('journey.studio.sections')}> <StudioRail section={open} onSection={setOpen} t={t} />
{SECTIONS.map(sec => (
<button type="button"
key={sec.id}
className={`st-rail-btn ${section === sec.id ? 'is-on' : ''}`}
onClick={() => setSection(sec.id)}
title={t(sec.labelKey)}
aria-label={t(sec.labelKey)}
>
<sec.icon size={18} strokeWidth={1.7} />
<span>{t(sec.labelKey)}</span>
</button>
))}
</nav>
<aside className="st-panel st-side"> <aside
{section === 'pages' && <PagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />} className="st-panel st-side"
{section === 'content' && ( id={RAIL_IDS.panel}
<ContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload} role="tabpanel"
aria-labelledby={RAIL_IDS.tab(open)}
>
{open === 'pages' && <StudioPagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
{open === 'content' && (
<StudioContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload}
providers={providers} onBrowseProvider={onBrowseProvider} /> providers={providers} onBrowseProvider={onBrowseProvider} />
)} )}
{section === 'elements' && <StudioElementsPanel page={page} t={t} source={source} />} {open === 'templates' && (
{section === 'stickers' && <StudioStickersPanel page={page} t={t} />} <StudioTemplatesPanel page={page} pxPerMm={pxPerMm} t={t} locale={locale} source={source} stats={stats}
{section === 'travel' && ( path={path} onOpenContent={() => setOpen('content')} />
)}
{open === 'elements' && <StudioElementsPanel page={page} t={t} source={source} />}
{open === 'travel' && (
<StudioTravelPanel <StudioTravelPanel
page={page} page={page}
stats={stats} stats={stats}
@@ -164,715 +125,9 @@ export function StudioSidebar({
onToggleStop={onToggleStop} onToggleStop={onToggleStop}
/> />
)} )}
{section === 'layers' && <StudioLayersPanel t={t} />} {open === 'theme' && <StudioThemePanel t={t} />}
{section === 'theme' && <StudioThemePanel t={t} />} {open === 'layers' && <StudioLayersPanel t={t} />}
{section === 'templates' && <TemplatesPanel page={page} pxPerMm={pxPerMm} t={t} onOpenContent={() => setSection('content')} />}
</aside> </aside>
</> </>
) )
} }
function PagesPanel({
page, pxPerMm, bookView, t,
}: { page: BookPageSetup; pxPerMm: number; bookView: boolean; t: (k: string) => string }) {
const doc = useStudioStore(s => s.doc)
const active = useStudioStore(s => s.activeSpread)
// The page a chat card points at, outlined here (journey-studio.md 4.2).
const hinted = useStudioStore(s => s.hintSpread)
const setActive = useStudioStore(s => s.setActiveSpread)
const addSpread = useStudioStore(s => s.addSpread)
const insertSpread = useStudioStore(s => s.insertSpread)
const duplicateSpread = useStudioStore(s => s.duplicateSpread)
const removeSpread = useStudioStore(s => s.removeSpread)
const moveSpread = useStudioStore(s => s.moveSpread)
const canEditSpread = useStudioStore(s => s.canEditSpread)
const spreads = doc?.spreads ?? []
const list = useElementSize<HTMLDivElement>()
const THUMB_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME)
/** Where a new spread would land: after the last inner one. */
const lastInner = spreads.reduce((last, sp, i) => (sp.role === 'inner' ? i : last), -1)
const fileInput = useRef<HTMLInputElement>(null)
const [importError, setImportError] = useState<string | null>(null)
return (
<>
<Head label={t('journey.studio.pages')} count={spreads.length} />
<div className="st-panel-scroll">
<div className="st-thumbs" ref={list.ref}>
{spreads.map((sp, i) => {
const editable = canEditSpread(i)
return (
<div className="st-thumb-row" key={sp.id}>
<button type="button"
className={`st-thumb ${i === active ? 'is-active' : ''} ${sp.id === hinted ? 'is-hinted' : ''}`}
onClick={() => setActive(i)}
>
<SpreadThumb
spread={sp}
page={page}
width={THUMB_W}
pxPerMm={pxPerMm}
folios={foliosOf(spreads, i, page.pageNumbers.startAt)}
fold={bookView}
className="st-thumb-sheet"
/>
<span className="st-thumb-label">
{sp.role === 'cover' ? t('journey.studio.cover')
: sp.role === 'back' ? t('journey.studio.backCover')
: folioLabel(foliosOf(spreads, i, page.pageNumbers.startAt))}
</span>
</button>
{/*
The actions sit on the thumbnail rather than in a menu bar of
their own: what you are acting on is the page you are pointing
at, and a toolbar somewhere else would need you to select
first and act second. The cover and the back cover have no
actions — a book has exactly one of each, and moving or
deleting them is not a thing anyone means to do.
*/}
{editable && (
<div className="st-thumb-actions">
<button type="button"
onClick={() => moveSpread(i, -1)}
disabled={!canEditSpread(i - 1)}
title={t('journey.studio.movePageUp')}
aria-label={t('journey.studio.movePageUp')}
>
<ChevronUp size={13} />
</button>
<button type="button"
onClick={() => moveSpread(i, 1)}
disabled={!canEditSpread(i + 1)}
title={t('journey.studio.movePageDown')}
aria-label={t('journey.studio.movePageDown')}
>
<ChevronDown size={13} />
</button>
<button type="button"
onClick={() => duplicateSpread(i)}
title={t('journey.studio.duplicatePage')}
aria-label={t('journey.studio.duplicatePage')}
>
<Copy size={12} />
</button>
<button type="button"
className="is-danger"
onClick={() => removeSpread(i)}
title={t('journey.studio.deletePage')}
aria-label={t('journey.studio.deletePage')}
>
<Trash2 size={12} />
</button>
</div>
)}
{/* Insert between two spreads, where the new one will go — a
single "add" at the end cannot express "another page here". */}
{editable && (
<button type="button"
className="st-thumb-insert"
onClick={() => addSpread(i)}
title={t('journey.studio.addPageAfter')}
aria-label={t('journey.studio.addPageAfter')}
>
<Plus size={12} />
</button>
)}
</div>
)
})}
</div>
{/*
Two ways to get a new page: an empty one, or somebody else's design.
Side by side because they answer the same question — the import is
not a setting buried in a menu, it is the other half of "add".
*/}
<div className="st-add-row">
<button type="button" className="st-add-page" onClick={() => addSpread(lastInner)}
title={t('journey.studio.addPage')} aria-label={t('journey.studio.addPage')}>
<Plus size={14} />
{t('journey.studio.addPageShort')}
</button>
<button type="button"
className="st-add-page is-import"
onClick={() => fileInput.current?.click()}
title={t('journey.studio.importSpreadHint')}
>
<FileDown size={14} />
{t('journey.studio.importSpread')}
</button>
</div>
<input
ref={fileInput}
type="file"
accept=".json,application/json"
hidden
onChange={async e => {
const file = e.target.files?.[0]
// Cleared straight away so choosing the same file twice in a row
// still fires a change event the second time.
e.target.value = ''
if (!file) return
setImportError(null)
// Checked before reading rather than after: the point of a limit on
// a file someone else wrote is not to parse it in the first place.
if (file.size > MAX_SPREAD_FILE_BYTES) { setImportError(t('journey.studio.importSpreadFailed')); return }
try {
const spread = importSpread(JSON.parse(await file.text()), page)
if (!spread) { setImportError(t('journey.studio.importSpreadFailed')); return }
insertSpread(lastInner, spread)
} catch {
setImportError(t('journey.studio.importSpreadFailed'))
}
}}
/>
{importError && <p className="st-add-error">{importError}</p>}
</div>
</>
)
}
/**
* The journey's own material.
*
* This is what separates a book maker from a drawing program: the pictures and
* the words are already written, and the job is putting them on pages. Clicking
* an item drops it on the current spread, centred, at a sensible size.
*
* Pictures can also come in here. A book is where you notice the one that
* never made it into the journal, and going back to the journey to add it, then
* finding the page again, was the one round trip Studio still asked for. What
* comes in goes through the journey store like any other upload, so the journal
* sees it the moment the panel does.
*/
function ContentPanel({
source, page, t, locale, canEdit, onUpload, providers = [], onBrowseProvider,
}: {
source: JourneySource
page: BookPageSetup
t: StudioT
locale: string
canEdit: boolean
onUpload: StudioUploader
providers?: { id: string; name: string }[]
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>
}) {
const [tab, setTab] = useState<'photos' | 'text'>('photos')
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<PhotoFilter>({ kind: 'all' })
const toast = useToast()
const entries = matchingEntries(source, query)
const admitted = photosFor(source, filter)
const photos = matchingPhotos(admitted, source, query)
const emptyKey = emptyKeyFor(source, filter, admitted)
const addElement = useStudioStore(s => s.addElement)
const active = useStudioStore(s => s.activeSpread)
const doc = useStudioStore(s => s.doc)
const spread = doc?.spreads[active]
const fileInput = useRef<HTMLInputElement>(null)
const [upload, setUpload] = useState<{ done: number; total: number } | null>(null)
/* A ref as well as the state: two drops in one second would both read the
state before either had set it. */
const sending = useRef(false)
const [dropOver, setDropOver] = useState(false)
/**
* Into the entry the browser is filtered to, otherwise into the gallery. The
* filter is the one thing on screen that names an entry, so it is also the
* one thing that can say where a picture belongs; the line under it says so
* before the press. Afterwards the new pictures get a filter of their own,
* because the gallery sorts them to its end, and twelve pictures that arrive
* out of sight read as twelve that did not arrive.
*/
const send = async (files: File[]) => {
if (sending.current || !files.length) return
sending.current = true
setUpload({ done: 0, total: files.length })
try {
const entryId = filter.kind === 'entry' ? filter.id : null
const sent = await onUpload(files, entryId, p => setUpload({ done: p.done, total: p.total }))
if (sent.photoIds.length) {
setFilter({ kind: 'recent', photoIds: sent.photoIds })
setQuery('')
toast.success(t('journey.photosUploaded', { count: sent.photoIds.length }))
}
} finally {
sending.current = false
setUpload(null)
}
}
/**
* From a photo provider (#2271): into the same place an upload goes, and the
* new pictures shown the same way, under their own filter.
*/
const pickFromProvider = async (provider: string) => {
if (!onBrowseProvider) return
const photoIds = await onBrowseProvider(provider, filter.kind === 'entry' ? filter.id : null)
if (photoIds.length) {
setFilter({ kind: 'recent', photoIds })
setQuery('')
}
}
/*
* The whole scrolling area takes a drop, not just the dashed cell: a drag
* from the desktop arrives with the pointer wherever it arrives, and a target
* seventy pixels square is one you miss. Only a file drag is claimed; the
* panel's own photo drags carry another type and go to the sheet.
*/
const dropZone = canEdit && tab === 'photos' ? {
onDragOver: (e: React.DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes('Files')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
setDropOver(true)
},
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => {
// Moving onto a child of the box is not leaving the box.
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropOver(false)
},
onDrop: (e: React.DragEvent<HTMLDivElement>) => {
const files = Array.from(e.dataTransfer.files ?? [])
if (!files.length) return
e.preventDefault()
setDropOver(false)
void send(files)
},
} : {}
const centre = (w: number, h: number) => {
const W = spread && spread.role !== 'inner' ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
}
const dropPhoto = (photoId: number) => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.55
addElement(active, {
id: uid('p'), kind: 'photo', frame: centre(side, side * 0.75),
rotation: 0, opacity: 1, locked: false,
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
} as BookElement)
}
/**
* One piece of the entry, dropped on the page and still attached to it.
*
* `format` is what separates a place name from a set of coordinates: both
* read the entry's location, and which of the two gets printed is a property
* of the binding rather than a second source — see the contract. `width`
* exists because a line of coordinates arriving inside a full-column
* selection rectangle looks like a bug; only the short marks pass it.
*/
const dropText = (
value: string,
size: number,
weight: 400 | 700,
entryId: number,
kind: 'entry.title' | 'entry.story' | 'entry.location' | 'entry.date',
opts: { format?: CoordFormat; width?: number; value?: string } = {},
) => {
const w = opts.width ?? page.pageWidth - 32
addElement(active, {
id: uid('t'), kind: 'text', frame: centre(w, size * 0.4 + 10),
rotation: 0, opacity: 1, locked: false,
text: value, font: 'sans', size, weight, italic: false,
align: 'left', leading: weight === 700 ? 1.1 : 1.55, tracking: weight === 700 ? -0.02 : 0,
color: '#1a1a1a',
binding: {
source: kind,
entryId,
...(opts.format ? { format: opts.format } : {}),
// The fact behind the words, for the marks whose words are formatted —
// see `value` in the contract.
...(opts.value ? { value: opts.value } : {}),
},
overridden: false,
} as BookElement)
}
const base = {
rotation: 0, opacity: 1, locked: false,
font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, stale: false,
}
/**
* A mood or a weather mark, drawn with the icon the journey page uses.
*
* `code` holds the key rather than the label, so the element carries what the
* entry recorded and the renderer decides how to draw it — which is what lets
* the icon and the palette come from one place.
*/
const dropMark = (variant: 'mood' | 'weather', code: string, label: string) =>
addElement(active, {
...base, id: uid('bd'), kind: 'badge', autoColor: true,
// Set here as well as in the contract: this object is cast, not parsed.
showIcon: true, showLabel: true, autoIconColor: true, iconColor: '#111111',
frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.062),
variant, text: label, sub: '', code, style: 'plain',
} as BookElement)
/** Both columns of an entry's pros and cons, as one element. */
const dropProsCons = (pros: string[], cons: string[]) => {
const rows = Math.max(pros.length, cons.length, 1)
addElement(active, {
...base, id: uid('li'), kind: 'list',
// Matches what ListView needs for `rows` lines at its largest line size,
// so the element arrives filled rather than with a third of it empty.
frame: centre(page.pageWidth * 0.72, Math.min(page.pageHeight * 0.6, 6 * (0.86 + 1.7 * rows))),
items: [
...pros.map(text => ({ text, tone: 'pro' as const })),
...cons.map(text => ({ text, tone: 'con' as const })),
],
layout: 'columns', showMarks: true,
proLabel: t('journey.editor.pros'),
conLabel: t('journey.editor.cons'),
} as BookElement)
}
return (
<>
<Head label={t('journey.studio.content')} />
<div className="st-search">
<Search size={14} />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('journey.studio.searchContent')}
aria-label={t('journey.studio.searchContent')}
spellCheck={false}
/>
{query && (
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
<div className="st-tabs">
<button type="button" className={tab === 'photos' ? 'is-on' : ''} onClick={() => setTab('photos')}>
{t('journey.studio.photos')} <em>{photos.length}</em>
</button>
<button type="button" className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
{t('journey.studio.entries')} <em>{entries.length}</em>
</button>
</div>
{/*
Which entry, as a row of its own under the tabs rather than a control
inside the search box: the search answers "which words" and this
answers "which day", and the two combine. The chip opens the same
popover the bar uses for formats, hung from its left edge because the
panel has no room to the right of it.
*/}
{tab === 'photos' && (
<StudioContentFilter
source={source}
filter={filter}
setFilter={setFilter}
admitted={admitted.length}
pageEntryId={spread?.entryId ?? null}
t={t}
locale={locale}
/>
)}
<div className={`st-panel-scroll ${dropOver ? 'is-drop-over' : ''}`} {...dropZone}>
{tab === 'photos' ? (
<>
{canEdit && (
<p className="st-hint st-upload-where">
{t(filter.kind === 'entry' ? 'journey.studio.uploadToEntry' : 'journey.studio.uploadToGallery')}
</p>
)}
<div className="st-photo-grid">
{/*
The first cell rather than a button above the grid: it is the
size and shape of what it produces, and it stays where the eye
already is. Disabled, not hidden, while a batch is on its way,
so the count has somewhere to be read.
*/}
{canEdit && (
<button type="button"
className="st-photo-cell is-upload"
onClick={() => fileInput.current?.click()}
disabled={!!upload}
title={t('journey.studio.uploadHint')}
>
<Upload size={18} strokeWidth={1.6} />
<span>
{upload
? t('journey.studio.uploading', { done: upload.done, total: upload.total })
: t('journey.studio.uploadPhotos')}
</span>
</button>
)}
{canEdit && onBrowseProvider && providers.map(p => (
<button type="button"
key={p.id}
className="st-photo-cell is-upload"
onClick={() => { void pickFromProvider(p.id) }}
disabled={!!upload}
title={t('journey.studio.fromProviderHint', { name: p.name })}
>
<ImagePlus size={18} strokeWidth={1.6} />
<span>{t('journey.studio.fromProvider', { name: p.name })}</span>
</button>
))}
{photos.map(p => (
<button type="button"
key={p.photoId}
className="st-photo-cell"
onClick={() => dropPhoto(p.photoId)}
title={t('journey.studio.addToPage')}
draggable
onDragStart={e => {
// HTML5 drag is the right tool for exactly this shape of
// interaction (one item, from a list, onto a target) and it
// gives us the thumbnail as the drag image for free. The
// canvas uses pointer events instead, because free transform
// needs a live position that a drop event cannot provide.
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId))
e.dataTransfer.effectAllowed = 'copy'
}}
>
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
</button>
))}
{!photos.length && <p className="st-hint">{t(emptyKey)}</p>}
</div>
{canEdit && (
<input
ref={fileInput}
type="file"
multiple
accept="image/*,.heic,.heif"
hidden
onChange={e => {
const files = Array.from(e.target.files ?? [])
// Cleared straight away, as the spread import does, so the
// same pictures can be chosen twice in a row.
e.target.value = ''
void send(files)
}}
/>
)}
</>
) : (
<div className="st-entries">
{entries.map(e => (
<div key={e.id} className="st-entry">
<div className="st-entry-head">{e.title || e.location || t('journey.studio.untitled')}</div>
{(e.date || e.location) && (
<div className="st-entry-meta">
{/* Two facts, two badges. Joined by a dot they read as one
string and the eye has to parse where the date ends. The
date follows the app's language, like every other date in
TREK — see utils/formatters.ts. */}
{e.date && <span className="st-badge">{formatDate(e.date, locale) ?? e.date}</span>}
{e.location && <span className="st-badge is-quiet">{e.location}</span>}
</div>
)}
<div className="st-row" style={{ marginTop: 7 }}>
{e.title && (
<button type="button" className="st-chip" onClick={() => dropText(e.title!, 22, 700, e.id, 'entry.title')}>
{t('journey.studio.addTitle')}
</button>
)}
{e.story && (
<button type="button" className="st-chip" onClick={() => dropText(e.story!, 10, 400, e.id, 'entry.story')}>
{t('journey.studio.addStory')}
</button>
)}
{e.location && (
<button type="button" className="st-chip" onClick={() => dropText(e.location!, 8, 700, e.id, 'entry.location')}>
{t('journey.studio.addPlace')}
</button>
)}
{/*
The day and the point. Both are set the way the rest of the
book sets them — the long date, coordinates in degrees — so a
mark placed here and one the auto layout placed read alike.
*/}
{e.date && (
<button type="button"
className="st-chip"
onClick={() => dropText(
formatBookDate(e.date!, locale), 8, 700, e.id, 'entry.date',
{ width: page.pageWidth * 0.36, value: e.date! },
)}
>
{t('journey.studio.dateMark')}
</button>
)}
{e.lat != null && e.lng != null && (
<button type="button"
className="st-chip"
onClick={() => dropText(
formatBookCoords(e.lat!, e.lng!, 'dms'), 8, 700, e.id, 'entry.location',
{ format: 'dms', width: page.pageWidth * 0.36, value: coordValue(e.lat!, e.lng!) },
)}
>
{t('journey.studio.coordsMark')}
</button>
)}
{/*
What the entry recorded besides its story. Each chip shows
the value rather than the field name — "Sunny" tells you
what you are about to place; "Weather" does not.
*/}
{e.mood && MOOD_CONFIG[e.mood] && (
<button type="button"
className="st-chip"
onClick={() => dropMark('mood', e.mood!, t(MOOD_CONFIG[e.mood!].label))}
>
{t(MOOD_CONFIG[e.mood].label)}
</button>
)}
{e.weather && WEATHER_CONFIG[e.weather] && (
<button type="button"
className="st-chip"
onClick={() => dropMark('weather', e.weather!, t(WEATHER_CONFIG[e.weather!].label))}
>
{t(WEATHER_CONFIG[e.weather].label)}
</button>
)}
{(e.pros.length > 0 || e.cons.length > 0) && (
<button type="button" className="st-chip" onClick={() => dropProsCons(e.pros, e.cons)}>
{t('journey.studio.addProsCons')}
<em>{e.pros.length + e.cons.length}</em>
</button>
)}
{/*
Not a drop like the others: this one takes you to the
entry's pictures. The words of a day and its pictures were
two tabs apart with nothing joining them, and the day is
how anyone thinks of the pictures.
*/}
{e.photoIds.length > 0 && (
<button type="button"
className="st-chip"
onClick={() => { setFilter({ kind: 'entry', id: e.id }); setTab('photos') }}
>
<Camera size={12} />
{t('journey.studio.entryPhotos')}
<em>{e.photoIds.length}</em>
</button>
)}
</div>
</div>
))}
{!entries.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
</div>
)}
</div>
</>
)
}
/**
* Layouts for the current spread.
*
* Each card draws the arrangement itself rather than naming it — "hero + story"
* means nothing until you have seen it, and a diagram is faster to read than a
* label anyway.
*/
function TemplatesPanel({
page, pxPerMm, t, onOpenContent,
}: { page: BookPageSetup; pxPerMm: number; t: (k: string) => string; onOpenContent: () => void }) {
const doc = useStudioStore(s => s.doc)
const active = useStudioStore(s => s.activeSpread)
const commit = useStudioStore(s => s.commit)
const spread = doc?.spreads[active]
const list = useElementSize<HTMLDivElement>()
const CARD_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME)
const doc2 = doc
const cardIsSingle = (doc2?.spreads[active]?.role ?? 'inner') !== 'inner'
// A cover card is one page wide, not two — scaling it as a spread would draw
// the layout at half size in the left half of the card.
const scale = useMemo(
() => CARD_W / (page.pageWidth * (cardIsSingle ? 1 : 2) * pxPerMm),
[CARD_W, page.pageWidth, pxPerMm, cardIsSingle],
)
if (!spread) return null
const single = spread.role !== 'inner'
/*
* The cover has its own set. It used to have none — the panel said covers
* are designed by hand, which was true and unhelpful: the cover is the page
* people care most about and the one they had no starting point for.
*/
const templates = single ? COVER_TEMPLATES : TEMPLATES
return (
<>
<Head label={t('journey.studio.templates')} count={templates.length} />
<div className="st-panel-scroll">
<StudioSavedLayouts page={page} width={CARD_W} pxPerMm={pxPerMm} t={t} />
{(
<div className="st-thumbs" ref={list.ref}>
{templates.map(tpl => {
const slots = tpl.build(page)
return (
<button
type="button"
key={tpl.id}
className="st-thumb"
onClick={() => {
let empties = 0
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => {
if (i !== active) return sp
const next = applyTemplate(sp, tpl, page)
empties = next.elements.filter(e => e.kind === 'photo' && e.photoId == null).length
return next
}),
}))
// The layout left frames waiting for a picture — so put the
// pictures within reach instead of making the user go and
// find the panel that holds them.
if (empties > 0) onOpenContent()
}}
>
<div
className="st-thumb-sheet"
style={{ width: CARD_W, height: page.pageHeight * pxPerMm * scale }}
>
{slots.map((slot, i) => (
<span
key={i}
className={`st-tpl-slot is-${slot.kind}`}
style={{
left: slot.frame.x * pxPerMm * scale,
top: slot.frame.y * pxPerMm * scale,
width: slot.frame.w * pxPerMm * scale,
height: Math.max(2, slot.frame.h * pxPerMm * scale),
}}
/>
))}
{!single && (
<span
className="st-tpl-fold"
style={{ left: page.pageWidth * pxPerMm * scale }}
/>
)}
</div>
<span className="st-thumb-label">{t(tpl.labelKey)}</span>
</button>
)
})}
</div>
)}
</div>
</>
)
}
@@ -1,34 +1,32 @@
import { bookStickerSlotColor, type BookElement, type BookStickerElement } from '@trek/shared'; import { bookStickerSlotColor, type BookElement, type BookStickerElement } from '@trek/shared';
import { bookSticker } from '@trek/shared/stickers'; import { bookSticker } from '@trek/shared/stickers';
import { RotateCcw, RotateCw } from 'lucide-react'; import type { ReactNode } from 'react';
import { Line, NumField, Section, Switch } from './StudioControls'; import { Line, Section } from './StudioControls';
import { Swatches } from './StudioSwatches'; import { Swatches } from './StudioSwatches';
import { stickerLabel, turned, withSlotColor } from './stickerLibrary'; import { stickerLabel, withSlotColor } from './stickerLibrary';
/** /**
* The inspector's part for a sticker: its colours slot by slot, and how it * The inspector of a sticker: its colours slot by slot first, then where it
* lies on the page (mirrored, turned, faded). * sits, which for a sticker is open from the start and holds its mirror and
* its turn (StudioPositionSection.tsx).
* *
* Every slot is offered with the colour it shows now, the sticker's own until * Every slot is offered with the colour it shows now, the sticker's own until
* somebody picks another, and choosing one writes the colours of the slots * somebody picks another, and choosing one writes the colours of the slots
* before it as well, so the list the document keeps always reads in slot order. * before it as well, so the list the document keeps always reads in slot order.
* Turning is here as well as in the folded position numbers because a sticker
* is the one element people set at an angle as a matter of course.
*/ */
type Translate = (k: string, params?: Record<string, string | number>) => string; type Translate = (k: string, params?: Record<string, string | number>) => string;
/** A quarter of a right angle and a little: what a sticker is tipped by, a step at a time. */
const TURN_STEP = 15;
export function StudioStickerInspector({ export function StudioStickerInspector({
el, el,
set, set,
position,
t, t,
}: { }: {
el: BookStickerElement; el: BookStickerElement;
set: (patch: Partial<BookElement>) => void; set: (patch: Partial<BookElement>) => void;
position: ReactNode;
t: Translate; t: Translate;
}) { }) {
const def = bookSticker(el.stickerId); const def = bookSticker(el.stickerId);
@@ -47,7 +45,7 @@ export function StudioStickerInspector({
</Line> </Line>
))} ))}
{el.colors.length > 0 && ( {el.colors.length > 0 && (
<div className="st-row" style={{ marginTop: 8 }}> <div className="st-row st-section-gap">
<button type="button" className="st-chip" onClick={() => set({ colors: [] })}> <button type="button" className="st-chip" onClick={() => set({ colors: [] })}>
{t('journey.studio.stickerResetColours')} {t('journey.studio.stickerResetColours')}
</button> </button>
@@ -55,49 +53,7 @@ export function StudioStickerInspector({
)} )}
</Section> </Section>
<Section label={t('journey.studio.stickerPlacement')}> {position}
<Switch label={t('journey.studio.stickerFlip')} on={el.flipX} onToggle={() => set({ flipX: !el.flipX })} />
<div className="st-row" style={{ marginTop: 10, alignItems: 'flex-end' }}>
<button
type="button"
className="st-chip is-icon"
onClick={() => set({ rotation: turned(el.rotation, -TURN_STEP) })}
title={t('journey.studio.stickerTurnLeft')}
aria-label={t('journey.studio.stickerTurnLeft')}
>
<RotateCcw size={14} />
</button>
<button
type="button"
className="st-chip is-icon"
onClick={() => set({ rotation: turned(el.rotation, TURN_STEP) })}
title={t('journey.studio.stickerTurnRight')}
aria-label={t('journey.studio.stickerTurnRight')}
>
<RotateCw size={14} />
</button>
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.rotation')}
value={el.rotation}
min={-180}
max={180}
step={1}
unit="°"
onChange={(v) => set({ rotation: v })}
/>
<NumField
label={t('journey.studio.opacity')}
value={Math.round(el.opacity * 100)}
min={0}
max={100}
step={5}
unit="%"
onChange={(v) => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
/>
</div>
</Section>
</> </>
); );
} }
@@ -1,12 +1,12 @@
import { BOOK_STICKER_THEMES, type BookPageSetup } from '@trek/shared'; import { BOOK_STICKER_THEMES, type BookPageSetup } from '@trek/shared';
import { bookStickersOf, type BookStickerDef } from '@trek/shared/stickers'; import { bookStickersOf, type BookStickerDef } from '@trek/shared/stickers';
import { Search, X } from 'lucide-react';
import { useMemo, useState, type DragEvent } from 'react'; import { useMemo, useState, type DragEvent } from 'react';
import { useStudioStore } from '../../store/studioStore'; import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-stickers.css'; import '../../styles/studio-stickers.css';
import { StickerArt } from './StickerView'; import { StickerArt } from './StickerView';
import { PanelHead } from './StudioPanelHead'; import { Shelf } from './StudioPanelHead';
import { StudioSearch } from './StudioSearch';
import { import {
STICKER_DRAG_TYPE, STICKER_DRAG_TYPE,
matchStickers, matchStickers,
@@ -18,9 +18,10 @@ import {
} from './stickerLibrary'; } from './stickerLibrary';
/** /**
* The sticker library, as a section of the rail: illustrations to put on a * The sticker library, as the Stickers tab of the Elements panel:
* page, grouped by theme, searchable in the reader's language, with the ones * illustrations to put on a page, grouped by theme, searchable in the
* this viewer placed last on a shelf of their own. * reader's language, with the ones this viewer placed last on a shelf of
* their own.
* *
* A click places a sticker in the middle of the page, a drag places it where * A click places a sticker in the middle of the page, a drag places it where
* it is let go. Either way it arrives selected, so the inspector beside the * it is let go. Either way it arrives selected, so the inspector beside the
@@ -87,44 +88,33 @@ export function StudioStickersPanel({ page, t }: { page: BookPageSetup; t: Trans
return ( return (
<> <>
<PanelHead label={t('journey.studio.stickers')} count={browsing ? undefined : matches.length} /> <StudioSearch
<div className="st-search"> value={query}
<Search size={14} /> onChange={setQuery}
<input label={t('journey.studio.searchStickers')}
value={query} clearLabel={t('common.clear')}
onChange={(e) => setQuery(e.target.value)} count={matches.length}
placeholder={t('journey.studio.searchStickers')} />
aria-label={t('journey.studio.searchStickers')}
spellCheck={false}
/>
{query && (
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
<div className="st-panel-scroll"> <div className="st-panel-scroll">
{browsing && recent.length > 0 && ( {browsing && recent.length > 0 && (
<div className="st-section"> <Shelf label={t('journey.studio.stickersRecent')}>
<div className="st-section-label">{t('journey.studio.stickersRecent')}</div>
<StickerGrid list={recent} t={t} onPick={place} /> <StickerGrid list={recent} t={t} onPick={place} />
</div> </Shelf>
)} )}
{/* Browsing, the library by theme; searching, one grid of what matches. */} {/* Browsing, the library by theme; searching, one grid of what matches. */}
{browsing ? ( {browsing ? (
BOOK_STICKER_THEMES.map((id) => ( BOOK_STICKER_THEMES.map((id) => (
<div className="st-section" key={id}> <Shelf key={id} label={stickerThemeLabel(t, id)}>
<div className="st-section-label">{stickerThemeLabel(t, id)}</div>
<StickerGrid list={bookStickersOf(id)} t={t} onPick={place} /> <StickerGrid list={bookStickersOf(id)} t={t} onPick={place} />
</div> </Shelf>
)) ))
) : ( ) : (
<div className="st-section"> <Shelf>
<StickerGrid list={matches} t={t} onPick={place} /> <StickerGrid list={matches} t={t} onPick={place} />
{!matches.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>} {!matches.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
</div> </Shelf>
)} )}
<p className="st-hint">{t('journey.studio.stickersHint')}</p> <p className="st-hint">{t('journey.studio.stickersHint')}</p>
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render } from '../../../tests/helpers/render';
import { AlphaSlider } from './StudioSwatches';
/**
* The opacity slider: a move is shown while it is made and committed once
* when it is let go, and a key let go on it that moved nothing (the Tab that
* brought the focus there) commits nothing, whatever value it last moved to.
*/
function slider(value: number) {
const onChange = vi.fn();
const view = render(<AlphaSlider color="#112233" value={value} onChange={onChange} label="Opacity" />);
const input = view.getByLabelText('Opacity') as HTMLInputElement;
return { ...view, onChange, input };
}
describe('the opacity slider', () => {
it('commits nothing when the focus arrives on it with Tab', () => {
const { input, onChange } = slider(1);
fireEvent.keyUp(input, { key: 'Tab' });
fireEvent.blur(input);
expect(onChange).not.toHaveBeenCalled();
});
it('commits an arrow key once, at the value it moved to', () => {
const { input, onChange } = slider(1);
fireEvent.keyDown(input, { key: 'ArrowLeft' });
fireEvent.change(input, { target: { value: '99' } });
fireEvent.keyUp(input, { key: 'ArrowLeft' });
fireEvent.keyUp(input, { key: 'Tab' });
expect(onChange.mock.calls).toEqual([
[0.99, false],
[0.99, true],
]);
});
it('does not take an old move to a value given since', () => {
const { input, onChange, rerender } = slider(0.4);
fireEvent.pointerDown(input);
fireEvent.change(input, { target: { value: '30' } });
fireEvent.pointerUp(input);
expect(onChange).toHaveBeenLastCalledWith(0.3, true);
onChange.mockClear();
// The same slider, now showing another colour's opacity: tabbing onto it changes nothing.
rerender(<AlphaSlider color="#112233" value={1} onChange={onChange} label="Opacity" />);
fireEvent.keyUp(input, { key: 'Tab' });
expect(onChange).not.toHaveBeenCalled();
});
it('commits a move the focus leaves before any key or pointer is let go', () => {
const { input, onChange } = slider(1);
fireEvent.keyDown(input, { key: 'ArrowLeft' });
fireEvent.change(input, { target: { value: '80' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenLastCalledWith(0.8, true);
});
});
@@ -116,11 +116,18 @@ export function AlphaSlider({ color, value, onChange, label }: {
const pct = Math.round(value * 100) const pct = Math.round(value * 100)
// What the slider was last moved to: a controlled input may still show the old value when it is let go. // What the slider was last moved to: a controlled input may still show the old value when it is let go.
const moved = useRef(value) const moved = useRef(value)
// Whether it moved since the last commit: a key let go on it (the Tab that brought the focus) is no change.
const dirty = useRef(false)
const move = (raw: string) => { const move = (raw: string) => {
moved.current = Math.max(0, Math.min(100, Number(raw))) / 100 moved.current = Math.max(0, Math.min(100, Number(raw))) / 100
dirty.current = true
onChange(moved.current, false) onChange(moved.current, false)
} }
const finish = () => onChange(moved.current, true) const finish = () => {
if (!dirty.current) return
dirty.current = false
onChange(moved.current, true)
}
return ( return (
<div className="st-alpha"> <div className="st-alpha">
<div className="st-alpha-track" style={{ ['--st-alpha-colour' as string]: color }}> <div className="st-alpha-track" style={{ ['--st-alpha-colour' as string]: color }}>
@@ -136,6 +143,7 @@ export function AlphaSlider({ color, value, onChange, label }: {
onChange={e => move(e.target.value)} onChange={e => move(e.target.value)}
onPointerUp={finish} onPointerUp={finish}
onKeyUp={finish} onKeyUp={finish}
onBlur={finish}
/> />
</div> </div>
<span className="st-alpha-value">{pct}%</span> <span className="st-alpha-value">{pct}%</span>
@@ -0,0 +1,88 @@
import { Shapes, Type } from 'lucide-react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { StudioTabPanel, StudioTabs } from './StudioTabs';
/**
* FE-STUDIO-TABS-001 to FE-STUDIO-TABS-004: the tab rows inside Studio's
* panels, as a screen reader and a keyboard meet them, and as a long word
* meets them.
*/
type Id = 'one' | 'two' | 'three';
function Row({ onChange = () => {} }: { onChange?: (id: Id) => void }) {
const [value, setValue] = useState<Id>('one');
return (
<>
<StudioTabs<Id>
idBase="t"
label="Kinds"
value={value}
columns={2}
onChange={(id) => {
setValue(id);
onChange(id);
}}
tabs={[
{ id: 'one', label: 'Klistermärken', icon: Type, count: 12 },
{ id: 'two', label: 'Two', icon: Shapes },
{ id: 'three', label: 'Three' },
]}
/>
<StudioTabPanel idBase="t" value={value} className="body">
<p>{value}</p>
</StudioTabPanel>
</>
);
}
describe('StudioTabs', () => {
it('FE-STUDIO-TABS-001: is a tab list naming its panel, with only the chosen tab in the Tab order', () => {
render(<Row />);
const list = screen.getByRole('tablist', { name: 'Kinds' });
const tabs = screen.getAllByRole('tab');
expect(tabs.map((t) => t.getAttribute('aria-selected'))).toEqual(['true', 'false', 'false']);
expect(tabs.map((t) => t.tabIndex)).toEqual([0, -1, -1]);
expect(tabs[0]?.getAttribute('aria-controls')).toBe('t-panel');
const panel = screen.getByRole('tabpanel');
expect(panel.getAttribute('aria-labelledby')).toBe('t-tab-one');
expect(panel.className).toBe('body');
expect(list.className).toContain('st-subtabs');
});
it('FE-STUDIO-TABS-002: opens a tab on a click and moves the focus with the arrows without opening', () => {
const onChange = vi.fn();
render(<Row onChange={onChange} />);
const [one, two, three] = screen.getAllByRole('tab');
one?.focus();
fireEvent.keyDown(one as HTMLElement, { key: 'ArrowRight' });
expect(document.activeElement).toBe(two);
fireEvent.keyDown(two as HTMLElement, { key: 'End' });
expect(document.activeElement).toBe(three);
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(three as HTMLElement);
expect(onChange).toHaveBeenCalledWith('three');
expect(screen.getByRole('tabpanel').textContent).toBe('three');
expect(three?.tabIndex).toBe(0);
});
it('FE-STUDIO-TABS-003: keeps a long word in a cell that breaks it, and the count as plain figures', () => {
const { container } = render(<Row />);
const label = screen.getByText('Klistermärken');
expect(label.className).toBe('st-subtab-label');
expect(container.querySelector('.st-subtab-count')?.textContent).toBe('12');
// A row with icons stacks each word under its icon, so the word has the whole cell.
expect(container.querySelector('.st-subtabs')?.classList.contains('has-icons')).toBe(true);
});
it('FE-STUDIO-TABS-004: lays a row without icons out flat', () => {
const { container } = render(
<StudioTabs idBase="u" label="Plain" value="a" onChange={() => {}} tabs={[{ id: 'a', label: 'A' }]} />
);
expect(container.querySelector('.st-subtabs')?.classList.contains('has-icons')).toBe(false);
expect(screen.getByRole('tab', { name: 'A' })).toBeTruthy();
});
});
+114
View File
@@ -0,0 +1,114 @@
import type { LucideIcon } from 'lucide-react';
import { useRef, type HTMLAttributes, type KeyboardEvent, type ReactNode } from 'react';
import '../../styles/studio-panels.css';
import { rovingTarget, runsRtl } from './studioRoving';
/**
* The tab rows inside Studio's panels: Content's photos and entries, the six
* kinds of thing the Elements panel adds.
*
* A grid rather than a strip, so every tab keeps its whole word: a strip of six
* cuts "Klistermärken" to "Kliste…" or wraps into a ragged second row, where a
* grid of three across wraps into an even one. A long word breaks inside its
* cell instead of leaving it. The count beside a label is plain figures in the
* label's own colour, quieter than the word, never a pill inside the tab.
*
* The keyboard is the rail's (studioRoving.ts): the arrows move along the row,
* Enter or Space opens the tab.
*/
interface StudioTab<T extends string> {
id: T;
label: string;
icon?: LucideIcon;
/** How many there are behind the tab, when that is worth reading before opening it. */
count?: number;
}
export function StudioTabs<T extends string>({
tabs,
value,
onChange,
label,
idBase,
columns = tabs.length,
}: {
tabs: readonly StudioTab<T>[];
value: T;
onChange: (id: T) => void;
/** The row's own name for a screen reader, which the tabs alone do not give. */
label: string;
/** The ids the tabs and their panel share; the panel is `${idBase}-panel`. */
idBase: string;
/** How many tabs share a line before the next one starts a second. */
columns?: number;
}) {
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
const next = rovingTarget(e.key, index, tabs.length, { vertical: false, rtl: runsRtl(e.currentTarget) });
if (next === null) return;
e.preventDefault();
buttons.current[next]?.focus();
};
return (
<div
className={`st-subtabs ${tabs.some((tab) => tab.icon) ? 'has-icons' : ''}`}
role="tablist"
aria-label={label}
style={{ gridTemplateColumns: `repeat(${Math.max(1, Math.min(columns, tabs.length))}, minmax(0, 1fr))` }}
>
{tabs.map((tab, i) => {
const on = tab.id === value;
const Icon = tab.icon;
return (
<button
type="button"
role="tab"
key={tab.id}
id={`${idBase}-tab-${tab.id}`}
aria-selected={on}
aria-controls={`${idBase}-panel`}
tabIndex={on ? 0 : -1}
className={`st-subtab ${on ? 'is-on' : ''}`}
ref={(el) => {
buttons.current[i] = el;
}}
onClick={() => onChange(tab.id)}
onKeyDown={(e) => onKeyDown(e, i)}
>
{Icon && <Icon size={15} strokeWidth={1.8} aria-hidden className="st-subtab-icon" />}
<span className="st-subtab-label">{tab.label}</span>
{tab.count != null && <span className="st-subtab-count">{tab.count}</span>}
</button>
);
})}
</div>
);
}
/** What a tab row opens: the region the chosen tab names. */
export function StudioTabPanel({
idBase,
value,
className,
children,
...rest
}: {
idBase: string;
value: string;
className?: string;
children: ReactNode;
} & Omit<HTMLAttributes<HTMLDivElement>, 'id' | 'role' | 'className' | 'children'>) {
return (
<div
{...rest}
id={`${idBase}-panel`}
role="tabpanel"
aria-labelledby={`${idBase}-tab-${value}`}
className={className}
>
{children}
</div>
);
}
@@ -41,10 +41,29 @@ function mount(el: BookTextElement, photos = [{ photoId: 4 }, { photoId: 5 }]) {
const toggle = (label: string) => screen.getByRole('button', { name: label }); const toggle = (label: string) => screen.getByRole('button', { name: label });
/** A number field, named by its label, followed by its value and unit. */ /** A number field, named by its label, followed by its value and unit. */
const num = (label: string) => screen.getByRole('spinbutton', { name: (name) => name.startsWith(label) }); const num = (label: string) => screen.getByRole('spinbutton', { name: (name) => name.startsWith(label) });
/** The case and placement group, folded on a text that uses none of it. */
const openPlacement = () => fireEvent.click(screen.getByRole('button', { name: 'journey.studio.fx.placement' }));
describe('case and placement', () => { describe('case and placement', () => {
it('folds away on a plain text and opens on one in small caps, set in the middle or fitted', () => {
const plain = mount(text());
expect(screen.getByRole('button', { name: 'journey.studio.fx.placement' }).getAttribute('aria-expanded')).toBe(
'false'
);
expect(screen.queryByRole('radio', { name: 'journey.studio.fx.caseSmall' })).toBeNull();
plain.unmount();
for (const over of [{ transform: 'smallcaps' }, { vAlign: 'middle' }, { fit: 'shrink' }]) {
const used = mount(text(over));
expect(screen.getByRole('button', { name: 'journey.studio.fx.placement' }).getAttribute('aria-expanded')).toBe(
'true'
);
used.unmount();
}
});
it('sets the case and where the lines sit', () => { it('sets the case and where the lines sit', () => {
const { set } = mount(text()); const { set } = mount(text());
openPlacement();
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.caseSmall' })); fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.caseSmall' }));
expect(set).toHaveBeenCalledWith({ transform: 'smallcaps' }); expect(set).toHaveBeenCalledWith({ transform: 'smallcaps' });
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.middle' })); fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.middle' }));
@@ -53,6 +72,7 @@ describe('case and placement', () => {
it('shrinks to fit, and gives the chosen size back when fitting is turned off', () => { it('shrinks to fit, and gives the chosen size back when fitting is turned off', () => {
const on = mount(text()); const on = mount(text());
openPlacement();
fireEvent.click(toggle('journey.studio.fx.fit')); fireEvent.click(toggle('journey.studio.fx.fit'));
expect(on.set).toHaveBeenCalledWith({ fit: 'shrink' }); expect(on.set).toHaveBeenCalledWith({ fit: 'shrink' });
on.unmount(); on.unmount();
@@ -12,15 +12,17 @@ import { bookTextIsDrawn } from '@trek/shared';
import '../../styles/studio-text.css'; import '../../styles/studio-text.css';
import { photoSrc } from './bookRender'; import { photoSrc } from './bookRender';
import { FocalPad } from './inspector/FocalPad';
import { textEffectsInUse, textPlacementInUse } from './inspector/inspectorModel';
import { Choice, Line, NumField, Section, Switch } from './StudioControls'; import { Choice, Line, NumField, Section, Switch } from './StudioControls';
import { FocalPad } from './StudioInspector';
import { Swatches } from './StudioSwatches'; import { Swatches } from './StudioSwatches';
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects'; import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
/** /**
* The text inspector's effects: how the letters are cased and where the lines * The text inspector's effects: how the letters are cased and where the lines
* sit, whether the text shrinks to its frame, and the outline, hard shadow, * sit, whether the text shrinks to its frame, and the outline, hard shadow,
* marker band, curve and fill (book-text-effects.schema.ts in shared). * marker band, curve and fill (book-text-effects.schema.ts in shared). Both
* groups start folded and open on a text that already uses them.
* *
* Every effect is a switch that arrives already looking right: an outline in * Every effect is a switch that arrives already looking right: an outline in
* the colour that stands out from the letters at a twentieth of their height, * the colour that stands out from the letters at a twentieth of their height,
@@ -49,7 +51,7 @@ const FILL_PHOTOS = 12;
function CaseAndPlace({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) { function CaseAndPlace({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
return ( return (
<Section label={t('journey.studio.fx.placement')}> <Section label={t('journey.studio.fx.placement')} defaultOpen={textPlacementInUse(el)}>
<Line label={t('journey.studio.fx.case')}> <Line label={t('journey.studio.fx.case')}>
<Choice <Choice
value={el.transform ?? 'none'} value={el.transform ?? 'none'}
@@ -352,11 +354,10 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
export function StudioTextEffects(props: Props) { export function StudioTextEffects(props: Props) {
const { el, set, t } = props; const { el, set, t } = props;
const any = !!(el.outline || el.shadow || el.highlight || el.curve || el.fillPaint);
return ( return (
<> <>
<CaseAndPlace el={el} set={set} t={t} /> <CaseAndPlace el={el} set={set} t={t} />
<Section label={t('journey.studio.fx.effects')} defaultOpen={any}> <Section label={t('journey.studio.fx.effects')} defaultOpen={textEffectsInUse(el)}>
<OutlineControls el={el} set={set} t={t} /> <OutlineControls el={el} set={set} t={t} />
<ShadowControls el={el} set={set} t={t} /> <ShadowControls el={el} set={set} t={t} />
<BandControls el={el} set={set} t={t} /> <BandControls el={el} set={set} t={t} />
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type { BookDocument, BookTextElement } from '@trek/shared'; import type { BookDocument, BookTextElement } from '@trek/shared';
import { BOOK_LOCKUPS, bookPageSetupSchema, normalizeBookDocument } from '@trek/shared'; import { BOOK_LOCKUPS, MAX_SPREAD_ELEMENTS, bookPageSetupSchema, normalizeBookDocument } from '@trek/shared';
import { fireEvent, render, screen } from '../../../tests/helpers/render'; import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore'; import { useStudioStore } from '../../store/studioStore';
@@ -73,11 +73,30 @@ describe('the Text styles shelf', () => {
expect(elements()).toEqual([]); expect(elements()).toEqual([]);
}); });
it('places nothing on a page without room for the whole set, so the book stays saveable', () => {
const shapes = Array.from({ length: MAX_SPREAD_ELEMENTS - 2 }, (_, i) => ({
id: `s${i}`,
kind: 'shape',
frame: { x: 0, y: 0, w: 5, h: 5 },
}));
const crowded = book();
useStudioStore
.getState()
.load(normalizeBookDocument({ ...crowded, spreads: crowded.spreads.map((sp) => ({ ...sp, elements: shapes })) }));
render(<StudioTextStyles page={page} source={source} t={t} />);
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.lockup.circle-badge' }));
expect(elements()).toHaveLength(MAX_SPREAD_ELEMENTS - 2);
expect(useStudioStore.getState().canUndo()).toBe(false);
expect(useStudioStore.getState().selection).toEqual([]);
});
it('opens the Elements panel on the type tab, the plain styles above the lockups', () => { it('opens the Elements panel on the type tab, the plain styles above the lockups', () => {
render(<StudioElementsPanel page={page} t={t} source={source} />); render(<StudioElementsPanel page={page} t={t} source={source} />);
expect(screen.getByRole('tab', { name: 'journey.studio.text' }).getAttribute('aria-selected')).toBe('true');
expect(screen.getByRole('button', { name: 'journey.studio.styleTitle' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'journey.studio.styleTitle' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'journey.studio.quoteMark' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.shapes' })); fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.shapes' }));
expect(screen.queryByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeNull(); expect(screen.queryByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeNull();
}); });
}); });
@@ -1,12 +1,13 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { BookLockup, BookLockupWords, BookPageSetup } from '@trek/shared'; import type { BookLockup, BookLockupWords, BookPageSetup } from '@trek/shared';
import { addBookLockup, bookElementId } from '@trek/shared'; import { MAX_SPREAD_ELEMENTS, addBookLockup, bookElementId } from '@trek/shared';
import { useElementSize } from '../../hooks/useElementSize'; import { useElementSize } from '../../hooks/useElementSize';
import { useStudioStore } from '../../store/studioStore'; import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-text.css'; import '../../styles/studio-text.css';
import { ElementView } from './SpreadView'; import { ElementView } from './SpreadView';
import { Shelf } from './StudioPanelHead';
import { firstFillPhoto } from './textEffects'; import { firstFillPhoto } from './textEffects';
import { import {
SHELF_GROUPS, SHELF_GROUPS,
@@ -97,7 +98,8 @@ export function StudioTextStyles({
const place = (lockup: BookLockup) => { const place = (lockup: BookLockup) => {
const els = placeLockup(lockup, { page, spread, words, gallery: source.photos, newId: bookElementId }); const els = placeLockup(lockup, { page, spread, words, gallery: source.photos, newId: bookElementId });
if (!els.length) return; // The page's own cap, as a duplicate keeps it: a set that would make the book unsaveable is not placed.
if (!els.length || !spread || spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) return;
commit((d) => ({ commit((d) => ({
...d, ...d,
spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)), spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)),
@@ -109,8 +111,7 @@ export function StudioTextStyles({
return ( return (
<> <>
{SHELF_GROUPS.map((group) => ( {SHELF_GROUPS.map((group) => (
<div className="st-section" key={group.id}> <Shelf key={group.id} label={t(`journey.studio.lockupGroup.${group.id}`)}>
<div className="st-section-label">{t(`journey.studio.lockupGroup.${group.id}`)}</div>
<div className="st-lockups"> <div className="st-lockups">
{shelfLockups(group.lockups).map((lockup) => ( {shelfLockups(group.lockups).map((lockup) => (
<LockupTile <LockupTile
@@ -123,7 +124,7 @@ export function StudioTextStyles({
/> />
))} ))}
</div> </div>
</div> </Shelf>
))} ))}
<p className="st-hint st-fx-hint">{t('journey.studio.textStylesHint')}</p> <p className="st-hint st-fx-hint">{t('journey.studio.textStylesHint')}</p>
</> </>
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, type ReactNode } from 'react'
import type { import type {
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement, BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
BookMetric, BookStatsElement, JourneyStats, BookMetric, BookStatsElement, JourneyStats,
@@ -14,14 +14,19 @@ import { Choice, Line, Picks, Section, Switch } from './StudioControls'
import { fetchRoads } from './roadRoute' import { fetchRoads } from './roadRoute'
import { useStudioStore } from '../../store/studioStore' import { useStudioStore } from '../../store/studioStore'
import { StudioThemeLink } from './theme/StudioThemeLink' import { StudioThemeLink } from './theme/StudioThemeLink'
import { mapZoomInUse, travelTypeInUse } from './inspector/inspectorModel'
/** /**
* Properties of the travel elements. * Properties of the travel elements.
* *
* Its own file because these four are a different kind of thing from a photo or * Its own file because these five are a different kind of thing from a photo or
* a paragraph: what you adjust is *what the element says*, not how a box looks. * a paragraph: what you adjust is *what the element says*, not how a box looks.
* Keeping them here leaves StudioInspector.tsx about the elements that have * Keeping them here leaves the inspector about the elements that have been
* been there since the beginning. * there since the beginning.
*
* In the order every kind follows: what the element says first (a map's scope,
* source and framing, a summary's figures, a mark's words), then where it sits,
* then how it looks (a map's line and layers, the type, the colours).
*/ */
export interface TravelInspectorProps { export interface TravelInspectorProps {
@@ -33,6 +38,8 @@ export interface TravelInspectorProps {
set: (patch: Partial<BookElement>) => void set: (patch: Partial<BookElement>) => void
t: (k: string) => string t: (k: string) => string
locale: string locale: string
/** The position and size group, which every kind places after what it is. */
position: ReactNode
} }
interface Props<T> { interface Props<T> {
@@ -41,7 +48,7 @@ interface Props<T> {
t: (k: string) => string t: (k: string) => string
} }
export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProps) { export function TravelInspector({ el, stats, path, set, t, position }: TravelInspectorProps) {
const theme = useStudioStore(s => s.doc?.theme) const theme = useStudioStore(s => s.doc?.theme)
if ( if (
el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries' el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries'
@@ -70,12 +77,16 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
</div> </div>
)} )}
{el.kind === 'map' && <MapProps el={el} set={set} t={t} stats={stats} journeyPath={path} />} {el.kind === 'map' && <MapContent el={el} set={set} t={t} stats={stats} journeyPath={path} />}
{el.kind === 'stats' && <StatsProps el={el} set={set} t={t} />} {el.kind === 'stats' && <StatsProps el={el} set={set} t={t} />}
{el.kind === 'countries' && <CountriesProps el={el} set={set} t={t} />} {el.kind === 'countries' && <CountriesProps el={el} set={set} t={t} />}
{el.kind === 'badge' && <BadgeProps el={el} set={set} t={t} />} {el.kind === 'badge' && <BadgeProps el={el} set={set} t={t} />}
{el.kind === 'list' && <ListProps el={el} set={set} t={t} />} {el.kind === 'list' && <ListProps el={el} set={set} t={t} />}
{position}
{el.kind === 'map' && <MapLook el={el} set={set} t={t} />}
{/* {/*
* Type, for all five alike. * Type, for all five alike.
* *
@@ -83,11 +94,12 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
* country's name over its outline — and until now the only thing you * country's name over its outline — and until now the only thing you
* could change about that was the size. A book set in a serif with its * could change about that was the size. A book set in a serif with its
* stats block in the default sans is a book with one element in the * stats block in the default sans is a book with one element in the
* wrong typeface and no way to fix it. * wrong typeface and no way to fix it. Folded, and open on an element
* already set in another face, weight or size.
*/} */}
<Section label={t('journey.studio.typography')} defaultOpen={false}> <Section label={t('journey.studio.typography')} defaultOpen={travelTypeInUse(el)}>
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} t={t} /> <StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} t={t} />
<div style={{ marginTop: 10 }}> <div className="st-section-gap">
<WeightLine <WeightLine
font={el.font} font={el.font}
weight={el.weight} weight={el.weight}
@@ -147,7 +159,7 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
onUnlink={() => set({ token: undefined } as Partial<BookElement>)} /> onUnlink={() => set({ token: undefined } as Partial<BookElement>)} />
</Line> </Line>
{el.kind === 'badge' && ( {el.kind === 'badge' && (
<div style={{ marginTop: 10 }}> <div className="st-section-gap">
<Switch <Switch
label={t('journey.studio.autoColour')} label={t('journey.studio.autoColour')}
on={el.autoColor} on={el.autoColor}
@@ -208,31 +220,9 @@ type MapPropsInput = Props<BookMapElement> & {
journeyPath: [number, number][][] journeyPath: [number, number][][]
} }
function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) { /** What a map shows: which trip, where the picture comes from and its style, the framing and the zoom. */
function MapContent({ el, set, t, stats, journeyPath }: MapPropsInput) {
const cartoKey = useCartoApiKey() const cartoKey = useCartoApiKey()
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
const [roadDone, setRoadDone] = useState(0)
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
/*
* One leg at a time, paced, and written into the document when it is done
* rather than leg by leg: a hundred commits would be a hundred undo steps and
* a hundred autosaves for one action.
*/
const loadRoads = async () => {
setRoadState('busy')
setRoadDone(0)
try {
const roads = await fetchRoads(
el.points.map(p => ({ lat: p.lat, lng: p.lng })),
{ onProgress: done => setRoadDone(done) },
)
set({ roads } as Partial<BookElement>)
} finally {
setRoadState('idle')
}
}
const sources = useMapSources(el.frame, el.points, { source: el.source, tileUrl: el.tileUrl }) const sources = useMapSources(el.frame, el.points, { source: el.source, tileUrl: el.tileUrl })
/** Only trips that put stops on the route: a map of the others is a blank. */ /** Only trips that put stops on the route: a map of the others is a blank. */
const tripsWithRoute = (stats?.trips ?? []).filter(t => t.points > 0) const tripsWithRoute = (stats?.trips ?? []).filter(t => t.points > 0)
@@ -328,7 +318,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
{el.source !== 'vector' && ( {el.source !== 'vector' && (
<> <>
<p className="st-hint" style={{ paddingTop: 8 }}> <p className="st-hint" style={{ paddingTop: 8 }}>
{t(SOURCE_HINT[sourceIdOf(el, sources)] ?? 'journey.studio.mapSourceHint')} {t(SOURCE_HINT[sourceIdOf(el, sources)]?.hintKey ?? 'journey.studio.mapSourceHint')}
</p> </p>
{/* {/*
What this map will actually print at, at the size it is now. What this map will actually print at, at the size it is now.
@@ -338,7 +328,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
{dpi != null && ( {dpi != null && (
<p className={`st-hint ${dpi < 150 ? 'st-stale' : ''}`} style={{ paddingTop: 6 }}> <p className={`st-hint ${dpi < 150 ? 'st-stale' : ''}`} style={{ paddingTop: 6 }}>
{t('journey.studio.mapPrintDpi')} {dpi} dpi {t('journey.studio.mapPrintDpi')} {dpi} dpi
{dpi < 150 && ` — ${t('journey.studio.mapPrintDpiLow')}`} {dpi < 150 && `, ${t('journey.studio.mapPrintDpiLow')}`}
</p> </p>
)} )}
</> </>
@@ -408,7 +398,7 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
</Section> </Section>
{el.source === 'tiles' && ( {el.source === 'tiles' && (
<Section label={t('journey.studio.mapZoom')} defaultOpen={false}> <Section label={t('journey.studio.mapZoom')} defaultOpen={mapZoomInUse(el)}>
<Choice <Choice
value={el.zoom ?? -1} value={el.zoom ?? -1}
options={[ options={[
@@ -419,7 +409,40 @@ function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
/> />
</Section> </Section>
)} )}
</>
)
}
/**
* How a map looks: the line of the route, whether it follows the roads, and
* which layers are drawn. After the position, like the look of every kind.
*/
function MapLook({ el, set, t }: Props<BookMapElement>) {
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
const [roadDone, setRoadDone] = useState(0)
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
/*
* One leg at a time, paced, and written into the document when it is done
* rather than leg by leg: a hundred commits would be a hundred undo steps and
* a hundred autosaves for one action.
*/
const loadRoads = async () => {
setRoadState('busy')
setRoadDone(0)
try {
const roads = await fetchRoads(
el.points.map(p => ({ lat: p.lat, lng: p.lng })),
{ onProgress: done => setRoadDone(done) },
)
set({ roads } as Partial<BookElement>)
} finally {
setRoadState('idle')
}
}
return (
<>
{/* {/*
How the line itself is drawn, and whether it follows real roads. How the line itself is drawn, and whether it follows real roads.
@@ -675,9 +698,9 @@ function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
} }
/** What each imagery source is for, said once where it is chosen. */ /** What each imagery source is for, said once where it is chosen. */
const SOURCE_HINT: Record<string, string> = { const SOURCE_HINT: Record<string, { hintKey: string }> = {
relief: 'journey.studio.mapSourceReliefHint', relief: { hintKey: 'journey.studio.mapSourceReliefHint' },
satellite: 'journey.studio.mapSourceSatelliteHint', satellite: { hintKey: 'journey.studio.mapSourceSatelliteHint' },
} }
/** The variants that draw something as well as saying something. */ /** The variants that draw something as well as saying something. */
@@ -1,14 +1,15 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Check, RouteOff } from 'lucide-react' import { Check, RouteOff } from 'lucide-react'
import type { import type {
BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, BookTravelContext, JourneyStats, BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, JourneyStats,
} from '@trek/shared' } from '@trek/shared'
import { import {
BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge, BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge,
bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats, bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats,
} from '@trek/shared' } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore' import { useStudioStore } from '../../store/studioStore'
import { PanelHead } from './StudioPanelHead' import { PanelHead, Shelf } from './StudioPanelHead'
import { studioTravelContext } from './studioTravelContext'
import { Section } from './StudioControls' import { Section } from './StudioControls'
import { TravelPreview } from './TravelPreview' import { TravelPreview } from './TravelPreview'
import { fetchRoads } from './roadRoute' import { fetchRoads } from './roadRoute'
@@ -84,10 +85,10 @@ function Card({ label, onClick, children, recommended = false, title }: {
/** /**
* Marked as the one to reach for, with a ring rather than with words. * Marked as the one to reach for, with a ring rather than with words.
* *
* The label sits in a grid cell about eighty pixels wide, where "Satellite · * The label sits in a grid cell about eighty pixels wide, where "Satellite,
* recommended" is two lines of ellipsis. A ring says the same thing in no * recommended" would take both of its lines. A ring says the same thing in
* space at all, and the tooltip carries the word for anyone who wonders why * no space at all, and the tooltip carries the word for anyone who wonders
* this card looks different. * why this card looks different.
*/ */
recommended?: boolean recommended?: boolean
title?: string title?: string
@@ -97,7 +98,7 @@ function Card({ label, onClick, children, recommended = false, title }: {
<button type="button" <button type="button"
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`} className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
onClick={onClick} onClick={onClick}
title={title ? `${label} — ${title}` : label} title={title ? `${label}: ${title}` : label}
> >
{children} {children}
<span className="st-travel-card-label">{label}</span> <span className="st-travel-card-label">{label}</span>
@@ -167,21 +168,11 @@ export function StudioTravelPanel({
} }
/* /*
* What the book engine's factories read: the journey, its recorded way and * What the book engine's factories read (studioTravelContext.ts). Every tile
* the words in the reader's language. Every tile below is built by the * below is built by the factory the engine's `add` calls. Where it lands is
* factory the engine's `add` calls. Where it lands is the panel's own: * the panel's own: centred across the spread, where `add` takes the right page.
* centred across the spread, where `add` takes the right page. The day word
* is the engine's too, handed over as its template: `t` leaves the `{day}`
* in it untouched, and `bookDayText` puts the number there.
*/ */
const travel: BookTravelContext = { const travel = studioTravelContext(stats, path, locale, t)
locale, stats, path, entries: [],
labels: {
day: t('journey.studio.day'), stations: t('journey.studio.stations'), summary: t('journey.studio.summary'),
countries: t('journey.studio.countries'), distance: t('journey.studio.metric.distance'),
pros: t('journey.editor.pros'), cons: t('journey.editor.cons'),
},
}
const at = (size: { w: number; h: number }) => centre(size.w, size.h) const at = (size: { w: number; h: number }) => centre(size.w, size.h)
/** /**
@@ -352,8 +343,7 @@ export function StudioTravelPanel({
<> <>
<PanelHead label={t('journey.studio.travel')} /> <PanelHead label={t('journey.studio.travel')} />
<div className="st-panel-scroll"> <div className="st-panel-scroll">
<div className="st-section"> <Shelf label={t('journey.studio.singleFigures')}>
<div className="st-section-label">{t('journey.studio.singleFigures')}</div>
<div className="st-travel-grid"> <div className="st-travel-grid">
{BOOK_METRICS.map(metric => { {BOOK_METRICS.map(metric => {
const el = singleEl(metric) const el = singleEl(metric)
@@ -364,7 +354,7 @@ export function StudioTravelPanel({
) )
})} })}
</div> </div>
</div> </Shelf>
{/* {/*
The switch is here, beside the maps and the figures it changes, and The switch is here, beside the maps and the figures it changes, and
@@ -389,7 +379,7 @@ export function StudioTravelPanel({
defaultOpen={false} defaultOpen={false}
badge={`${stops.length - excludedCount}/${stops.length}`} badge={`${stops.length - excludedCount}/${stops.length}`}
> >
<ul className="st-stops"> <ul className="st-route-stops">
{stops.map((stop, i) => { {stops.map((stop, i) => {
const entryId = stop.entryId const entryId = stop.entryId
const on = !stop.excluded const on = !stop.excluded
@@ -406,14 +396,14 @@ export function StudioTravelPanel({
*/ */
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} /> const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
return ( return (
<li key={entryId ?? `place-${i}`} className={`st-stop ${on ? '' : 'is-off'}`}> <li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
{/* {/*
One line: the day, then the name, then the switch. The One line: the day, then the name, then the switch. The
name and the day were stacked, which doubled the height name and the day were stacked, which doubled the height
of a list whose whole job is being read down. of a list whose whole job is being read down.
*/} */}
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>} {stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
<span className="st-stop-label">{stop.label || t('journey.studio.untitled')}</span> <span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
{/* {/*
The country only where there is more than one to tell The country only where there is more than one to tell
apart. On a journey inside one country it is the same apart. On a journey inside one country it is the same
@@ -443,8 +433,7 @@ export function StudioTravelPanel({
</Section> </Section>
)} )}
<div className="st-section"> <Shelf label={t('journey.studio.routeMap')}>
<div className="st-section-label">{t('journey.studio.routeMap')}</div>
{stats.points.length ? ( {stats.points.length ? (
<> <>
<div className="st-travel-grid"> <div className="st-travel-grid">
@@ -506,8 +495,8 @@ export function StudioTravelPanel({
trip nobody wrote about would place an empty frame. trip nobody wrote about would place an empty frame.
*/} */}
{tripsWithRoute.length > 1 && ( {tripsWithRoute.length > 1 && (
<div style={{ marginTop: 12 }}> <div className="st-shelf-sub">
<div className="st-section-label">{t('journey.studio.mapPerTrip')}</div> <h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
<div className="st-travel-grid"> <div className="st-travel-grid">
{tripsWithRoute.map(trip => { {tripsWithRoute.map(trip => {
const el = mapEl('minimal', { tripId: trip.id }) const el = mapEl('minimal', { tripId: trip.id })
@@ -528,10 +517,9 @@ export function StudioTravelPanel({
) : ( ) : (
<p className="st-hint">{t('journey.studio.noRoute')}</p> <p className="st-hint">{t('journey.studio.noRoute')}</p>
)} )}
</div> </Shelf>
<div className="st-section"> <Shelf label={t('journey.studio.countries')}>
<div className="st-section-label">{t('journey.studio.countries')}</div>
{stats.countries.length ? ( {stats.countries.length ? (
<> <>
{/* {/*
@@ -574,10 +562,9 @@ export function StudioTravelPanel({
) : ( ) : (
<p className="st-hint">{t('journey.studio.noCountries')}</p> <p className="st-hint">{t('journey.studio.noCountries')}</p>
)} )}
</div> </Shelf>
<div className="st-section"> <Shelf label={t('journey.studio.marks')}>
<div className="st-section-label">{t('journey.studio.marks')}</div>
<div className="st-travel-grid"> <div className="st-travel-grid">
{marks.map(({ el, label }) => ( {marks.map(({ el, label }) => (
<Card key={el.id} label={label} onClick={() => place(el)}> <Card key={el.id} label={label} onClick={() => place(el)}>
@@ -585,7 +572,7 @@ export function StudioTravelPanel({
</Card> </Card>
))} ))}
</div> </div>
</div> </Shelf>
</div> </div>
</> </>
) )
+4 -1
View File
@@ -53,9 +53,12 @@ export async function warmBookFaces(
* Warms the faces of the book the Studio holds whenever the set of faces it * Warms the faces of the book the Studio holds whenever the set of faces it
* uses changes; typing into a text does not, so an edit costs nothing here. * uses changes; typing into a text does not, so an edit costs nothing here.
*/ */
/** A key compares by code units: the same faces give the same key whatever the locale. */
const byCodeUnits = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
export function useWarmBookFaces(): void { export function useWarmBookFaces(): void {
const doc = useStudioStore((s) => s.doc); const doc = useStudioStore((s) => s.doc);
const key = useMemo(() => (doc ? bookFacesUsed(doc).map(faceSpec).sort().join('|') : ''), [doc]); const key = useMemo(() => (doc ? bookFacesUsed(doc).map(faceSpec).sort(byCodeUnits).join('|') : ''), [doc]);
useEffect(() => { useEffect(() => {
const current = useStudioStore.getState().doc; const current = useStudioStore.getState().doc;
// A face that will not load is drawn in its fallback, which the picker already shows; nothing to tell anyone. // A face that will not load is drawn in its fallback, which the picker already shows; nothing to tell anyone.
@@ -0,0 +1,67 @@
import { useRef, type PointerEvent } from 'react';
import { photoSrc } from '../bookRender';
/**
* Where the picture is anchored inside its frame.
*
* Two sliders were the first draft and they were the wrong instrument: a focal
* point is one position in a plane, not two unrelated numbers, and nobody can
* predict what "X 0.62" does to a photograph. Dragging the point over the
* actual image answers the question by showing it. The pad is a picture of the
* photograph, so it is laid out the way the photograph is, left to right in
* every reading direction.
*/
export function FocalPad({
photoId,
x,
y,
onDrag,
onCommit,
hint,
}: {
photoId: number;
x: number;
y: number;
onDrag: (x: number, y: number) => void;
onCommit: (x: number, y: number) => void;
hint: string;
}) {
const box = useRef<HTMLDivElement>(null);
const last = useRef({ x, y });
const from = (e: PointerEvent) => {
const r = box.current?.getBoundingClientRect();
if (!r || !r.width || !r.height) return last.current;
const nx = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
const ny = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
last.current = { x: nx, y: ny };
return last.current;
};
const dot = { left: `${x * 100}%`, top: `${y * 100}%` }; // rtl-lint-disable: a point on the photo
return (
<div className="st-focal-wrap">
<div
className="st-focal"
ref={box}
onPointerDown={(e) => {
(e.target as Element).setPointerCapture(e.pointerId);
const p = from(e);
onDrag(p.x, p.y);
}}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture?.(e.pointerId)) return;
const p = from(e);
onDrag(p.x, p.y);
}}
onPointerUp={() => onCommit(last.current.x, last.current.y)}
>
<img src={photoSrc(photoId, false)} alt="" draggable={false} />
<span className="st-focal-dot" style={dot} />
</div>
<p className="st-hint st-focal-hint">{hint}</p>
</div>
);
}
@@ -0,0 +1,88 @@
import type { BookDocument } from '@trek/shared';
import { bookPageSetupSchema, bookThemeOf } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { useStudioStore } from '../../../store/studioStore';
import { StudioInspector } from '../StudioInspector';
/**
* FE-STUDIO-DOCINSP-001 to FE-STUDIO-DOCINSP-004: the inspector with nothing
* selected. It says what it is for, shows the page's background, the book's
* theme with the way to the Theme panel, and the page numbers; the page size
* sits in its head.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
function load(extra: Partial<BookDocument> = {}) {
useStudioStore.getState().load({
version: 1,
title: 'T',
page,
spreads: [{ id: 'sp1', role: 'inner', background: null, elements: [], parked: [], entryId: null }],
...extra,
} as BookDocument);
useStudioStore.getState().select([]);
}
function draw(onOpenPanel?: (section: string) => void) {
return render(
<StudioInspector
spreadIndex={0}
page={page}
stats={null}
path={[]}
source={{ entries: [], photos: [], photoEntries: {} }}
setPageNumbers={() => {}}
t={(k: string) => k}
locale="en"
onOpenPanel={onOpenPanel}
/>
);
}
const groups = (c: HTMLElement) =>
[...c.querySelectorAll('.st-section.is-fold > h3 .st-section-label')].map((l) => l.textContent);
beforeEach(() => load());
describe('the inspector with nothing selected', () => {
it('FE-STUDIO-DOCINSP-001: opens on what it is for, then the page, the theme and the page numbers', () => {
const { container } = draw();
expect(container.querySelector('.st-what-label')?.textContent).toBe('journey.studio.document');
expect(container.querySelector('.st-head-meta')?.textContent).toBe('journey.studio.pageHint 210 × 210 mm');
expect(container.querySelector('.st-empty-note')?.textContent).toBe('journey.studio.inspectorEmpty');
expect(groups(container)).toEqual([
'journey.studio.paint.background',
'studio.theme.panel',
'journey.studio.pageNumbers',
]);
});
it('FE-STUDIO-DOCINSP-002: offers a theme to a book without one, and opens the Theme panel for it', () => {
const onOpenPanel = vi.fn();
const { container } = draw(onOpenPanel);
expect(container.querySelector('.st-theme-sum')).toBeNull();
expect(screen.getByText('studio.theme.intro')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'studio.theme.choose' }));
expect(onOpenPanel).toHaveBeenCalledWith('theme');
});
it('FE-STUDIO-DOCINSP-003: shows the theme a book has, as the panel draws it, with its name', () => {
load({ theme: bookThemeOf('nordic') });
const onOpenPanel = vi.fn();
const { container } = draw(onOpenPanel);
const sheet = container.querySelector('.st-theme-sum .st-theme-sheet') as HTMLElement | null;
expect(sheet?.style.background).not.toBe('');
expect(container.querySelectorAll('.st-theme-sum .st-theme-dots > span')).toHaveLength(3);
expect(container.querySelector('.st-theme-sum-name')?.textContent).toBe('studio.theme.name.nordic');
fireEvent.click(screen.getByRole('button', { name: 'studio.theme.change' }));
expect(onOpenPanel).toHaveBeenCalledWith('theme');
});
it('FE-STUDIO-DOCINSP-004: offers no way to the panel when the shell gives none', () => {
draw();
expect(screen.queryByRole('button', { name: 'studio.theme.choose' })).toBeNull();
});
});
@@ -0,0 +1,165 @@
import type { BookPageNumbers, BookPageSetup } from '@trek/shared';
import { BOOK_THEME_PRESETS } from '@trek/shared';
import { MousePointerClick, Palette } from 'lucide-react';
import { useStudioStore } from '../../../store/studioStore';
import '../../../styles/studio-panels.css';
import { StudioBackgroundSection } from '../StudioBackgroundSection';
import { Choice, Line, NumField, Section, Switch } from '../StudioControls';
import { PanelHead } from '../StudioPanelHead';
import type { JourneySource, StudioT } from '../StudioSidebar';
import { Swatches } from '../StudioSwatches';
import { ThemeSheet } from '../theme/ThemeSheet';
/**
* The inspector with nothing selected: the page and the book.
*
* What the panel is for comes first, in a line at its top, since an empty
* inspector is the first thing a new book shows. Then the page that is open,
* its background; the book's theme, as the Theme panel draws it, with the way
* to that panel; and the page numbers, which are no property of any element,
* because the number a page carries depends on where its spread sits. The page
* size sits in the head.
*/
type Folio = BookPageNumbers['position'];
const FOLIO_POSITIONS: readonly Folio[] = ['outer', 'inner', 'centre'];
/** The book's theme as a little page and its name, and the button to the Theme panel. */
function ThemeSummary({ t, onOpenTheme }: { t: StudioT; onOpenTheme?: () => void }) {
const theme = useStudioStore((s) => s.doc?.theme);
const preset = theme?.id ? BOOK_THEME_PRESETS.find((p) => p.id === theme.id) : undefined;
return (
<Section label={t('studio.theme.panel')}>
{theme ? (
<div className="st-theme-sum">
<ThemeSheet palette={theme.palette} display={theme.fonts.display} />
{preset && <span className="st-theme-sum-name">{t(preset.labelKey)}</span>}
</div>
) : (
<p className="st-hint st-hint-before">{t('studio.theme.intro')}</p>
)}
{onOpenTheme && (
<button type="button" className="st-chip" onClick={onOpenTheme}>
<Palette size={13} aria-hidden />
{t(theme ? 'studio.theme.change' : 'studio.theme.choose')}
</button>
)}
</Section>
);
}
/** Whether the pages carry numbers, and where, from which number, how large, in what colour. */
function PageNumbersSection({
folios,
setPageNumbers,
t,
}: {
folios: BookPageNumbers;
setPageNumbers: (patch: Partial<BookPageNumbers>) => void;
t: StudioT;
}) {
return (
<Section label={t('journey.studio.pageNumbers')}>
<Switch
label={t('journey.studio.pageNumbers')}
on={folios.show}
onToggle={() => setPageNumbers({ show: !folios.show })}
/>
{folios.show && (
<>
<div className="st-section-gap">
<Line label={t('journey.studio.position')}>
<Choice
value={folios.position}
ariaLabel={t('journey.studio.position')}
options={FOLIO_POSITIONS.map((pos) => ({ value: pos, label: t(`journey.studio.folio.${pos}`) }))}
onPick={(position) => setPageNumbers({ position })}
/>
</Line>
</div>
<div className="st-grid2 st-section-gap">
<NumField
label={t('journey.studio.folioStart')}
value={folios.startAt}
min={0}
max={9999}
step={1}
onChange={(v) => setPageNumbers({ startAt: Math.round(v) })}
/>
<NumField
label={t('journey.studio.size')}
value={folios.size}
min={4}
max={48}
step={0.5}
unit="pt"
onChange={(v) => setPageNumbers({ size: v })}
/>
</div>
<div className="st-section-gap">
<NumField
label={t('journey.studio.folioMargin')}
value={folios.margin}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={(v) => setPageNumbers({ margin: v })}
/>
</div>
<div className="st-section-gap">
<Switch
label={t('journey.studio.folioAuto')}
on={folios.autoColor}
onToggle={() => setPageNumbers({ autoColor: !folios.autoColor })}
/>
</div>
{/* Picking a colour is the other way to turn automatic off, so the row stays reachable and simply
reads as inactive. */}
<div className={`st-section-gap ${folios.autoColor ? 'is-quiet' : ''}`}>
<Line label={t('journey.studio.colour')} wide>
<Swatches value={folios.color} onPick={(c) => setPageNumbers({ color: c, autoColor: false })} />
</Line>
</div>
</>
)}
</Section>
);
}
export function StudioDocumentInspector({
spreadIndex,
page,
source,
setPageNumbers,
onOpenTheme,
t,
}: {
spreadIndex: number;
page: BookPageSetup;
source: JourneySource;
setPageNumbers: (patch: Partial<BookPageNumbers>) => void;
/** Opens the Theme panel in the rail; without it the summary offers no button. */
onOpenTheme?: () => void;
t: StudioT;
}) {
return (
<aside className="st-panel st-inspector">
<PanelHead
label={t('journey.studio.document')}
meta={`${t('journey.studio.pageHint')} ${page.pageWidth} × ${page.pageHeight} mm`}
/>
<div className="st-panel-scroll">
<p className="st-empty-note">
<MousePointerClick size={15} aria-hidden />
<span>{t('journey.studio.inspectorEmpty')}</span>
</p>
<StudioBackgroundSection spreadIndex={spreadIndex} page={page} source={source} t={t} />
<ThemeSummary t={t} onOpenTheme={onOpenTheme} />
<PageNumbersSection folios={page.pageNumbers} setPageNumbers={setPageNumbers} t={t} />
</div>
</aside>
);
}
@@ -0,0 +1,91 @@
import type { BookElement, BookIconElement, BookTheme } from '@trek/shared';
import { useMemo, useState, type ReactNode } from 'react';
import { iconComponent, iconLabel, searchIcons } from '../iconLibrary';
import { NumField, Section } from '../StudioControls';
import { StudioSearch } from '../StudioSearch';
import { Swatches } from '../StudioSwatches';
import { StudioThemeLink } from '../theme/StudioThemeLink';
/** How many icons the swap grid offers at once. A search narrows it below this. */
const ICON_CHOICES = 60;
/**
* The inspector of an icon: the drawing first, swapped from a search of the
* library, then where it sits, then its colour and the weight of its line.
*
* A search rather than the whole library: the inspector is a column beside a
* page, and its job here is swapping one drawing for a near neighbour. Swapping
* keeps the position, size and colour that deleting and re-placing would lose.
*/
export function StudioIconInspector({
el,
theme,
set,
position,
t,
}: {
el: BookIconElement;
theme: BookTheme | undefined;
set: (patch: Partial<BookElement>) => void;
position: ReactNode;
t: (k: string) => string;
}) {
const [query, setQuery] = useState('');
// A page of results, memoised: the inspector re-renders on every pointer move of a drag.
const choices = useMemo(() => searchIcons(query).slice(0, ICON_CHOICES), [query]);
return (
<>
<Section label={t('journey.studio.icon')}>
<StudioSearch
value={query}
onChange={setQuery}
label={t('journey.studio.searchIcons')}
clearLabel={t('common.clear')}
/>
<div className="st-mini-shapes">
{choices.map((name) => {
const Glyph = iconComponent(name);
return (
<button
type="button"
key={name}
className={`st-mini-shape ${el.name === name ? 'is-on' : ''}`}
onClick={() => set({ name })}
aria-label={name}
aria-pressed={el.name === name}
title={iconLabel(name)}
>
<Glyph strokeWidth={1.7} />
</button>
);
})}
</div>
{!choices.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
</Section>
{position}
<Section label={t('journey.studio.look')}>
<Swatches value={el.color} onPick={(color) => set({ color })} />
<StudioThemeLink
theme={theme}
token={el.token}
t={t}
onLink={(token, color) => set({ color, token })}
onUnlink={() => set({ token: undefined })}
/>
<div className="st-section-gap">
<NumField
label={t('journey.studio.lineWidth')}
value={el.lineWidth}
min={0.25}
max={4}
step={0.25}
onChange={(v) => set({ lineWidth: v })}
/>
</div>
</Section>
</>
);
}
@@ -0,0 +1,201 @@
import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread } from '@trek/shared';
import { tidyGroups, withoutGroup } from '@trek/shared';
import type { ReactNode } from 'react';
import { useStudioStore } from '../../../store/studioStore';
import { PhotoCropExtras, PhotoLookSections } from '../photo/StudioPhotoLook';
import { FRAME_SHAPES } from '../shapes';
import { Choice, Line, NumField, Section } from '../StudioControls';
import { EdgeSection } from '../StudioEdgeControls';
import { ShapeGlyph } from '../StudioElementsPanel';
import { FRAME_STYLE_OPTIONS } from '../studioOptions';
import { FocalPad } from './FocalPad';
import { maskInUse } from './inspectorModel';
/**
* The inspector of a photo: the crop first (how the picture fills its frame,
* where it is anchored, how far it is zoomed, and the frame filling the page),
* then where it sits, then its look (the filter, the frame around it, the
* corner), and folded under those the cut to a shape, the cut edge, the border
* and shadow, and the two raster effects. Each folded group opens on a photo
* that uses it.
*/
type Set = (patch: Partial<BookElement>) => void;
const FITS = ['cover', 'contain'] as const;
const FILTERS = ['none', 'bw', 'warm', 'cool', 'fade', 'contrast'] as const;
/**
* Stretch the picture over the page it is on, or over both pages, bleed
* included: the commonest layout in any photo book and the hardest to make by
* hand, since a millimetre short leaves a white hairline on the trimmed page.
*
* One commit: the frame, the rotation put back to zero (a turned picture
* leaves bare corners), the cover crop and the move to the back are one press
* and come back with one undo. Which page is the one under the frame's centre;
* a cover has only the one.
*/
function filled(sp: BookSpread, el: BookPhotoElement, page: BookPageSetup, scope: 'page' | 'spread'): BookSpread {
const bleed = page.bleed;
const onRight = sp.role === 'inner' && scope === 'page' && el.frame.x + el.frame.w / 2 >= page.pageWidth;
const frame = {
x: onRight ? page.pageWidth - bleed : -bleed,
y: -bleed,
w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed,
h: page.pageHeight + 2 * bleed,
};
// A picture that is the page is nobody's lockup: it leaves its group, as the assistant's fill takes it out.
const photo = { ...withoutGroup(el), frame, rotation: 0, fit: 'cover' } as BookElement;
return tidyGroups({ ...sp, elements: [photo, ...sp.elements.filter((e) => e.id !== el.id)] });
}
export function StudioPhotoInspector({
el,
one,
spreadIndex,
page,
set,
live,
position,
t,
}: {
el: BookPhotoElement;
/** A single photo is selected, so the page can be filled with it. */
one: boolean;
spreadIndex: number;
page: BookPageSetup;
set: Set;
live: Set;
position: ReactNode;
t: (k: string, params?: Record<string, string | number>) => string;
}) {
const commit = useStudioStore((s) => s.commit);
const role = useStudioStore((s) => s.doc?.spreads[spreadIndex]?.role);
const fill = (scope: 'page' | 'spread') =>
commit((d) => ({
...d,
spreads: d.spreads.map((sp, i) => (i === spreadIndex ? filled(sp, el, page, scope) : sp)),
}));
return (
<>
<Section label={t('journey.studio.crop')}>
<Choice
value={el.fit}
ariaLabel={t('journey.studio.crop')}
options={FITS.map((f) => ({ value: f, label: t(`journey.studio.fit.${f}`) }))}
onPick={(fit) => set({ fit })}
/>
{/*
An empty frame has no picture to aim at, and asking for one by id
produced a request for /api/photos/null/thumbnail and a broken image
under a caption telling you to drag it.
*/}
{el.photoId != null ? (
<FocalPad
photoId={el.photoId}
x={el.focalX}
y={el.focalY}
onDrag={(x, y) => live({ focalX: x, focalY: y })}
onCommit={(x, y) => set({ focalX: x, focalY: y })}
hint={t('journey.studio.focalHint')}
/>
) : (
<p className="st-hint st-hint-after">{t('journey.studio.emptyFrame')}</p>
)}
<PhotoCropExtras el={el} set={set} live={live} t={t} />
{one && (
<div className="st-row st-photo-fill">
<button type="button" className="st-chip" onClick={() => fill('page')} title={t('journey.studio.fillHint')}>
{t('journey.studio.fillPage')}
</button>
{role === 'inner' && (
<button
type="button"
className="st-chip"
onClick={() => fill('spread')}
title={t('journey.studio.fillHint')}
>
{t('journey.studio.fillSpread')}
</button>
)}
</div>
)}
</Section>
{position}
<Section label={t('journey.studio.look')}>
<Choice
value={el.filter}
ariaLabel={t('journey.studio.look')}
options={FILTERS.map((f) => ({ value: f, label: t(`journey.studio.filter.${f}`) }))}
onPick={(filter) => set({ filter })}
/>
<div className="st-section-gap">
<Line label={t('journey.studio.frameStyle')} wide>
<Choice
value={el.frameStyle}
ariaLabel={t('journey.studio.frameStyle')}
options={FRAME_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
onPick={(frameStyle) => set({ frameStyle })}
/>
</Line>
</div>
{/* A corner radius and a mask are the same idea at different resolutions, so the radius steps
aside once a shape is cut; it did not, and edited a property the renderer ignored. */}
{!maskInUse(el) && (
<div className="st-section-gap">
<NumField
label={t('journey.studio.radius')}
value={el.radius}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={(v) => set({ radius: v })}
/>
</div>
)}
</Section>
<Section label={t('journey.studio.mask')} defaultOpen={maskInUse(el)}>
<div className="st-mini-shapes">
<button
type="button"
className={`st-mini-shape ${el.mask ? '' : 'is-on'}`}
onClick={() => set({ mask: null })}
title={t('journey.studio.maskNone')}
aria-label={t('journey.studio.maskNone')}
aria-pressed={!el.mask}
>
<span className="st-mini-none" />
</button>
{FRAME_SHAPES.filter((sh) => sh !== 'rect').map((sh) => (
<button
type="button"
key={sh}
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
onClick={() => set({ mask: sh })}
aria-label={sh}
aria-pressed={el.mask === sh}
>
<ShapeGlyph shape={sh} />
</button>
))}
</div>
</Section>
<EdgeSection
edge={el.edge}
edgeSize={el.edgeSize}
outline={maskInUse(el) ? (el.mask ?? null) : null}
onSet={(patch) => set(patch)}
t={t}
/>
<PhotoLookSections el={el} set={set} live={live} t={t} />
</>
);
}
@@ -0,0 +1,114 @@
import type { BookElement } from '@trek/shared';
import { RotateCcw, RotateCw } from 'lucide-react';
import { NumField, Section, Switch } from '../StudioControls';
import { turned } from '../stickerLibrary';
import { positionInUse } from './inspectorModel';
/**
* Where the selected element sits, how big it is, how it is turned and how
* strongly it shows: one group, the same for every kind.
*
* Folded unless the element is turned or faded, or is a sticker: everything in
* here can be done by dragging, which is how it is actually done, and the
* numbers are for the one time in ten you need the same margin on two pages.
* The turn buttons tip the element a step at a time, which a sticker or a
* photo set at an angle wants more often than a typed figure. A sticker's
* mirror is here as well, since it is how the drawing lies on the page.
*
* With several selected the group keeps the angle, the opacity, the turn and,
* when they are all stickers, the mirror, and each writes to every one of
* them. Only the frame's numbers go: one x for four elements would stack them
* on top of each other. The sticker inspector used to carry its own turn,
* angle and opacity beside these, so a sticker had two of each.
*/
/** A quarter of a right angle and a little: what the turn buttons tip an element by. */
const TURN_STEP = 15;
export function StudioPositionSection({
el,
sel,
set,
t,
}: {
/** The first of the selection, whose figures the group shows. */
el: BookElement;
/** The whole selection, `el` among it. */
sel: readonly BookElement[];
set: (patch: Partial<BookElement>) => void;
t: (k: string) => string;
}) {
const { frame } = el;
const one = sel.length <= 1;
const turnLeft = t('journey.studio.stickerTurnLeft');
const turnRight = t('journey.studio.stickerTurnRight');
const mirror = el.kind === 'sticker' && sel.every((e) => e.kind === 'sticker');
return (
<Section label={t('journey.studio.position')} defaultOpen={sel.some((e) => positionInUse(e))}>
{one && (
<div className="st-grid2">
<NumField label="X" value={frame.x} unit="mm" onChange={(v) => set({ frame: { ...frame, x: v } })} />
<NumField label="Y" value={frame.y} unit="mm" onChange={(v) => set({ frame: { ...frame, y: v } })} />
<NumField
label={t('journey.studio.width')}
value={frame.w}
min={4}
unit="mm"
onChange={(v) => set({ frame: { ...frame, w: v } })}
/>
<NumField
label={t('journey.studio.height')}
value={frame.h}
min={4}
unit="mm"
onChange={(v) => set({ frame: { ...frame, h: v } })}
/>
</div>
)}
<div className={`st-grid2 ${one ? 'st-position-row' : ''}`}>
<NumField
label={t('journey.studio.rotation')}
value={el.rotation}
min={-180}
max={180}
step={1}
unit="°"
onChange={(v) => set({ rotation: v })}
/>
<NumField
label={t('journey.studio.opacity')}
value={Math.round(el.opacity * 100)}
min={0}
max={100}
step={5}
unit="%"
onChange={(v) => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
/>
</div>
<div className="st-row st-position-row">
<button
type="button"
className="st-chip is-icon"
onClick={() => set({ rotation: turned(el.rotation, -TURN_STEP) })}
title={turnLeft}
aria-label={turnLeft}
>
<RotateCcw size={14} />
</button>
<button
type="button"
className="st-chip is-icon"
onClick={() => set({ rotation: turned(el.rotation, TURN_STEP) })}
title={turnRight}
aria-label={turnRight}
>
<RotateCw size={14} />
</button>
</div>
{mirror && (
<Switch label={t('journey.studio.stickerFlip')} on={el.flipX} onToggle={() => set({ flipX: !el.flipX })} />
)}
</Section>
);
}
@@ -0,0 +1,71 @@
import type { BookElement, BookShapeElement, BookShapeId, BookTheme } from '@trek/shared';
import type { ReactNode } from 'react';
import { SHAPE_GROUPS } from '../shapes';
import { NumField, Section } from '../StudioControls';
import { EdgeSection } from '../StudioEdgeControls';
import { ShapeGlyph } from '../StudioElementsPanel';
import { StudioShapeFill, StudioShapeOutline } from '../StudioShapePaint';
/**
* The inspector of a shape: which shape it is and its corner first, then where
* it sits, then its fill, and folded under that its outline and its cut edge,
* each open on a shape that has one.
*/
export function StudioShapeInspector({
el,
spreadIndex,
selection,
theme,
set,
position,
t,
}: {
el: BookShapeElement;
spreadIndex: number;
selection: readonly string[];
theme: BookTheme | undefined;
set: (patch: Partial<BookElement>) => void;
position: ReactNode;
t: (k: string) => string;
}) {
return (
<>
<Section label={t('journey.studio.shape')}>
{/* Every shape, not just the two the element started as: swapping a placed shape keeps its position,
size and colour, which deleting and re-adding would not. */}
<div className="st-mini-shapes">
{SHAPE_GROUPS.flatMap((g) => g.shapes).map((sh: BookShapeId) => (
<button
type="button"
key={sh}
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
onClick={() => set({ shape: sh })}
aria-label={sh}
aria-pressed={el.shape === sh}
>
<ShapeGlyph shape={sh} />
</button>
))}
</div>
<div className="st-section-gap">
<NumField
label={t('journey.studio.radius')}
value={el.radius}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={(v) => set({ radius: v })}
/>
</div>
</Section>
{position}
<StudioShapeFill el={el} spreadIndex={spreadIndex} selection={selection} theme={theme} set={set} t={t} />
<StudioShapeOutline el={el} theme={theme} set={set} t={t} />
<EdgeSection edge={el.edge} edgeSize={el.edgeSize} outline={el.shape} onSet={(patch) => set(patch)} t={t} />
</>
);
}
@@ -0,0 +1,199 @@
import type { BookElement, BookSpread, BookTextElement, BookTheme } from '@trek/shared';
import { MAX_BOOK_TEXT_SIZE, formatBookCoords } from '@trek/shared';
import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Italic } from 'lucide-react';
import type { ReactNode } from 'react';
import { italicTitle } from '../fontPicker';
import { Choice, Line, NumField, Section } from '../StudioControls';
import { StudioFontPicker } from '../StudioFontPicker';
import type { JourneySource } from '../StudioSidebar';
import { Swatches } from '../StudioSwatches';
import { StudioTextEffects } from '../StudioTextEffects';
import { WeightLine } from '../StudioTypeControls';
import { StudioThemeLink } from '../theme/StudioThemeLink';
/**
* The inspector of a text: its words first, then where it sits, then how it is
* set (the typeface, its size and spacing, the alignment), its colour, and the
* rarer settings folded under them: the case and the place of the lines in the
* frame, and the effects. Each folded group opens on a text that uses it.
*/
type Set = (patch: Partial<BookElement>) => void;
const ALIGNS = [
{ value: 'left', icon: AlignLeft, labelKey: 'journey.studio.align.left' },
{ value: 'center', icon: AlignCenter, labelKey: 'journey.studio.align.center' },
{ value: 'right', icon: AlignRight, labelKey: 'journey.studio.align.right' },
{ value: 'justify', icon: AlignJustify, labelKey: 'journey.studio.align.justify' },
] as const;
/** What the text says, and for a bound coordinate, how the point is written. */
function TextContent({
el,
set,
live,
source,
t,
}: {
el: BookTextElement;
set: Set;
live: Set;
source: JourneySource;
t: (k: string) => string;
}) {
const binding = el.binding;
return (
<Section label={t('journey.studio.text')}>
<textarea
className="st-input st-textarea"
value={el.text}
rows={5}
aria-label={t('journey.studio.text')}
onChange={(e) => live({ text: e.target.value, overridden: true })}
onBlur={(e) => set({ text: e.target.value, overridden: true })}
/>
{binding && !el.overridden && <p className="st-hint st-hint-after">{t('journey.studio.boundHint')}</p>}
{/*
How a point is set. Only a coordinate binding has the question, and
answering it re-sets the words on the spot rather than at the next open.
Deliberately not `overridden`: choosing a format is not writing the text.
*/}
{binding?.source === 'entry.location' && binding.format && (
<div className="st-section-gap">
<Line label={t('journey.studio.coordsMark')}>
<Choice
value={binding.format}
ariaLabel={t('journey.studio.coordsMark')}
options={[
{ value: 'dms' as const, label: t('journey.studio.coordsDms') },
{ value: 'decimal' as const, label: t('journey.studio.coordsDecimal') },
]}
onPick={(format) => {
const entry = source.entries.find((e) => e.id === binding.entryId);
const text =
entry?.lat != null && entry.lng != null ? formatBookCoords(entry.lat, entry.lng, format) : el.text;
set({ binding: { ...binding, format }, text });
}}
/>
</Line>
</div>
)}
</Section>
);
}
/** How the words are set: each family set in itself, its weight, size, spacing and alignment. */
function TextType({ el, set, t }: { el: BookTextElement; set: Set; t: (k: string) => string }) {
return (
<Section label={t('journey.studio.typography')}>
{/* Each family set in itself, because a list of names tells you nothing about a typeface. */}
<StudioFontPicker font={el.font} weight={el.weight} onPick={(patch) => set(patch)} t={t} />
<div className="st-section-gap">
<WeightLine font={el.font} weight={el.weight} onPick={(weight) => set({ weight })} t={t} />
</div>
<div className="st-grid2 st-section-gap">
<NumField
label={t('journey.studio.size')}
value={el.size}
min={4}
max={MAX_BOOK_TEXT_SIZE}
step={0.5}
unit="pt"
onChange={(v) => set({ size: v })}
/>
<NumField
label={t('journey.studio.leading')}
value={el.leading}
min={0.7}
max={3}
step={0.05}
onChange={(v) => set({ leading: v })}
/>
</div>
<div className="st-section-gap">
<NumField
label={t('journey.studio.tracking')}
value={el.tracking}
min={-0.2}
max={1.5}
step={0.01}
unit="em"
onChange={(v) => set({ tracking: v })}
/>
</div>
<div className="st-section-gap">
<Line label={t('journey.studio.align')}>
<div className="st-row">
{ALIGNS.map(({ value, icon: Icon, labelKey }) => (
<button
type="button"
key={value}
className={`st-chip is-icon ${el.align === value ? 'is-on' : ''}`}
onClick={() => set({ align: value })}
aria-label={t(labelKey)}
title={t(labelKey)}
aria-pressed={el.align === value}
>
<Icon size={14} />
</button>
))}
{/* Italic sits with the alignment because both are things you do to a line rather than to a typeface. */}
<button
type="button"
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
onClick={() => set({ italic: !el.italic })}
title={italicTitle(el.font, el.weight, t)}
aria-label={t('journey.studio.italic')}
aria-pressed={el.italic}
>
<Italic size={14} />
</button>
</div>
</Line>
</div>
</Section>
);
}
export function StudioTextInspector({
el,
set,
live,
source,
spread,
theme,
position,
t,
}: {
el: BookTextElement;
/** One undo step. */
set: Set;
/** A change while typing or dragging. */
live: Set;
source: JourneySource;
spread: BookSpread | undefined;
theme: BookTheme | undefined;
/** The position and size group, which every kind places after what it is. */
position: ReactNode;
t: (k: string) => string;
}) {
return (
<>
<TextContent el={el} set={set} live={live} source={source} t={t} />
{position}
<TextType el={el} set={set} t={t} />
<Section label={t('journey.studio.colour')}>
<Swatches value={el.color} onPick={(color) => set({ color })} />
<StudioThemeLink
theme={theme}
token={el.token}
t={t}
onLink={(token, color) => set({ color, token })}
onUnlink={() => set({ token: undefined })}
/>
</Section>
<StudioTextEffects el={el} set={set} live={live} photos={source.photos} spread={spread} t={t} />
</>
);
}
@@ -0,0 +1,109 @@
import type { BookLineElement, BookPhotoElement, BookShapeElement, BookTextElement } from '@trek/shared';
import {
bookLineElementSchema,
bookMapElementSchema,
bookPhotoElementSchema,
bookShapeElementSchema,
bookStatsElementSchema,
bookStickerElementSchema,
bookTextElementSchema,
} from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
edgeInUse,
mapZoomInUse,
maskInUse,
ornamentInUse,
photoEdgesInUse,
positionInUse,
selectionKey,
shapeOutlineInUse,
textEffectsInUse,
textPlacementInUse,
travelTypeInUse,
} from './inspectorModel';
/**
* FE-STUDIO-INSPMODEL-001 to FE-STUDIO-INSPMODEL-008: which folded groups of
* the inspector an element already uses, read off the element alone.
*/
const FRAME = { x: 10, y: 10, w: 40, h: 30 };
const text = (over: Record<string, unknown> = {}): BookTextElement =>
bookTextElementSchema.parse({ id: 't', kind: 'text', frame: FRAME, text: 'Lisbon', ...over });
const photo = (over: Record<string, unknown> = {}): BookPhotoElement =>
bookPhotoElementSchema.parse({ id: 'p', kind: 'photo', frame: FRAME, photoId: 3, ...over });
const shape = (over: Record<string, unknown> = {}): BookShapeElement =>
bookShapeElementSchema.parse({ id: 's', kind: 'shape', frame: FRAME, ...over });
const line = (over: Record<string, unknown> = {}): BookLineElement =>
bookLineElementSchema.parse({ id: 'l', kind: 'line', frame: FRAME, ...over });
describe('the groups an element uses', () => {
it('FE-STUDIO-INSPMODEL-001: opens the position on a turned, faded or sticker element only', () => {
expect(positionInUse(shape())).toBe(false);
expect(positionInUse(shape({ rotation: 4 }))).toBe(true);
expect(positionInUse(shape({ opacity: 0.6 }))).toBe(true);
const sticker = bookStickerElementSchema.parse({ id: 'k', kind: 'sticker', frame: FRAME, stickerId: 'sun' });
expect(positionInUse(sticker)).toBe(true);
});
it('FE-STUDIO-INSPMODEL-002: reads case, the lines in the frame and fitting as the placement of a text', () => {
expect(textPlacementInUse(text())).toBe(false);
expect(textPlacementInUse(text({ transform: 'none', vAlign: 'top', fit: 'none' }))).toBe(false);
expect(textPlacementInUse(text({ transform: 'upper' }))).toBe(true);
expect(textPlacementInUse(text({ vAlign: 'bottom' }))).toBe(true);
expect(textPlacementInUse(text({ fit: 'shrink' }))).toBe(true);
});
it('FE-STUDIO-INSPMODEL-003: counts any one effect as the effects of a text', () => {
expect(textEffectsInUse(text())).toBe(false);
expect(textEffectsInUse(text({ outline: { color: '#ffffff', width: 0.5 } }))).toBe(true);
expect(textEffectsInUse(text({ curve: { type: 'arc', bend: 40, start: 0 } }))).toBe(true);
});
it('FE-STUDIO-INSPMODEL-004: reads a drawn or dashed stroke as the outline of a shape', () => {
expect(shapeOutlineInUse(shape({ stroke: null, strokeWidth: 0 }))).toBe(false);
expect(shapeOutlineInUse(shape({ stroke: '#141414', strokeWidth: 0 }))).toBe(false);
expect(shapeOutlineInUse(shape({ stroke: '#141414', strokeWidth: 0.5 }))).toBe(true);
expect(shapeOutlineInUse(shape({ strokeStyle: 'dashed' }))).toBe(true);
});
it('FE-STUDIO-INSPMODEL-005: reads the cut, the edge, the border and the ornament off the element', () => {
expect(edgeInUse(undefined)).toBe(false);
expect(edgeInUse('straight')).toBe(false);
expect(edgeInUse('zigzag')).toBe(true);
expect(maskInUse(photo())).toBe(false);
expect(maskInUse(photo({ mask: 'rect' }))).toBe(false);
expect(maskInUse(photo({ mask: 'heart' }))).toBe(true);
expect(photoEdgesInUse(photo())).toBe(false);
expect(photoEdgesInUse(photo({ border: { width: 3, color: '#ffffff' } }))).toBe(true);
expect(ornamentInUse(line())).toBe(false);
expect(ornamentInUse(line({ ornament: 'sun' }))).toBe(true);
});
it('FE-STUDIO-INSPMODEL-006: keys the groups by the selection, so another selection mounts them afresh', () => {
expect(selectionKey(['a'])).toBe('a');
expect(selectionKey(['a', 'b'])).toBe('a|b');
expect(selectionKey(['a', 'b'])).not.toBe(selectionKey(['b', 'a']));
expect(selectionKey([])).toBe('');
});
it('FE-STUDIO-INSPMODEL-007: reads a zoom of its own as the zoom of a map', () => {
const map = (over: Record<string, unknown> = {}) =>
bookMapElementSchema.parse({ id: 'm', kind: 'map', frame: FRAME, source: 'tiles', ...over });
expect(mapZoomInUse(map())).toBe(false);
expect(mapZoomInUse(map({ zoom: null }))).toBe(false);
expect(mapZoomInUse(map({ zoom: 11 }))).toBe(true);
expect(mapZoomInUse(map({ zoom: 0 }))).toBe(true);
});
it('FE-STUDIO-INSPMODEL-008: reads another face, weight or size as the type of a travel element', () => {
const stats = (over: Record<string, unknown> = {}) =>
bookStatsElementSchema.parse({ id: 'st', kind: 'stats', frame: FRAME, ...over });
expect(travelTypeInUse(stats())).toBe(false);
expect(travelTypeInUse(stats({ font: 'serif' }))).toBe(true);
expect(travelTypeInUse(stats({ weight: 400 }))).toBe(true);
expect(travelTypeInUse(stats({ textScale: 0.85 }))).toBe(true);
});
});
@@ -0,0 +1,88 @@
import type {
BookElement,
BookLineElement,
BookMapElement,
BookPhotoElement,
BookShapeElement,
BookStatsElement,
BookTextElement,
} from '@trek/shared';
import { photoLookOf } from '@trek/shared';
/**
* Which of the inspector's folded groups an element already uses.
*
* The groups people set once and rarely return to (the frame's numbers, the
* effects, a photo's duotone) start folded, so the panel opens on what is
* worth reading. A group the element already uses opens anyway: a turned
* sticker shows its angle, a text in small caps shows its case, a photo with
* a border shows its border. The panel remembers nothing between selections,
* so the answer is always read off the element itself.
*/
/**
* The position and size: open on an element that is turned or faded, and on
* a sticker, which people set at an angle as a matter of course.
*/
export function positionInUse(el: BookElement): boolean {
return el.kind === 'sticker' || el.rotation !== 0 || el.opacity < 1;
}
/** Case, the place of the lines in the frame and shrinking to fit: anything but upright lines at the top. */
export function textPlacementInUse(el: BookTextElement): boolean {
return (el.transform ?? 'none') !== 'none' || (el.vAlign ?? 'top') !== 'top' || el.fit === 'shrink';
}
/** An outline, a hard shadow, a marker band, a curve or a fill other than the colour. */
export function textEffectsInUse(el: BookTextElement): boolean {
return !!(el.outline || el.shadow || el.highlight || el.curve || el.fillPaint);
}
/** A shape's outline: drawn at a width, or set to dashes or dots. */
export function shapeOutlineInUse(el: BookShapeElement): boolean {
return (!!el.stroke && el.strokeWidth > 0) || el.strokeStyle !== 'solid';
}
/** A cut edge (zigzag, scallop, torn and the rest) on a shape or a photo. */
export function edgeInUse(edge: string | undefined): boolean {
return (edge ?? 'straight') !== 'straight';
}
/** A photo cut to a shape other than its own rectangle. */
export function maskInUse(el: BookPhotoElement): boolean {
return !!el.mask && el.mask !== 'rect';
}
/** A photo's border, hard shadow or die-cut outline. */
export function photoEdgesInUse(el: BookPhotoElement): boolean {
const look = photoLookOf(el);
return !!(look.border || look.shadow || look.cutout);
}
/** Something in the middle of a drawn line: a sun, a heart, a dot. */
export function ornamentInUse(el: BookLineElement): boolean {
return el.ornament !== 'none';
}
/** A map held at a zoom of its own rather than the one that fits its route. */
export function mapZoomInUse(el: BookMapElement): boolean {
return el.zoom != null;
}
/**
* A travel element set in another face, weight or size than it is placed in:
* the sans at 700, at the drawn size (shared/src/book/book-travel.ts). The five
* travel kinds share these three fields.
*/
export function travelTypeInUse(el: Pick<BookStatsElement, 'font' | 'weight' | 'textScale'>): boolean {
return el.font !== 'sans' || el.weight !== 700 || el.textScale !== 1;
}
/**
* The key the inspector mounts its groups under. A new selection mounts them
* afresh, so every group opens or folds for the element now selected rather
* than staying as it was left for the one before.
*/
export function selectionKey(ids: readonly string[]): string {
return ids.join('|');
}
@@ -0,0 +1,320 @@
import type { BookDocument, BookElement, JourneyStats } from '@trek/shared';
import {
bookIconElementSchema,
bookLineElementSchema,
bookMapElementSchema,
bookPageSetupSchema,
bookPhotoElementSchema,
bookShapeElementSchema,
bookStatsElementSchema,
bookStickerElementSchema,
bookTextElementSchema,
} from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
import { useStudioStore } from '../../../store/studioStore';
import { StudioInspector } from '../StudioInspector';
/**
* FE-STUDIO-INSPORDER-001 to FE-STUDIO-INSPORDER-011: the inspector's groups
* per kind, in the one order every kind follows (what it is and says, where it
* sits, how it looks, the effects, arrange); the folded groups opening on an
* element that uses them and on nothing else; and every group mounted afresh
* for the next selection.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
const FRAME = { x: 20, y: 30, w: 60, h: 40 };
/** The value, or a failed test saying it was missing. */
function must<T>(value: T | null | undefined): T {
if (value == null) throw new Error('expected a value');
return value;
}
const text = (over: Record<string, unknown> = {}) =>
bookTextElementSchema.parse({ id: 't1', kind: 'text', frame: FRAME, text: 'Harbour', ...over }) as BookElement;
const photo = (over: Record<string, unknown> = {}) =>
bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame: FRAME, photoId: 12, ...over }) as BookElement;
const shape = (over: Record<string, unknown> = {}) =>
bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame: FRAME, ...over }) as BookElement;
const icon = () => bookIconElementSchema.parse({ id: 'i1', kind: 'icon', frame: FRAME, name: 'Plane' }) as BookElement;
const sticker = () =>
bookStickerElementSchema.parse({ id: 'k1', kind: 'sticker', frame: FRAME, stickerId: 'lemon' }) as BookElement;
const line = () => bookLineElementSchema.parse({ id: 'l1', kind: 'line', frame: FRAME }) as BookElement;
const map = () =>
bookMapElementSchema.parse({
id: 'm1',
kind: 'map',
frame: FRAME,
countries: ['IS'],
points: [{ lat: 64.14, lng: -21.94, label: 'Reykjavik' }],
}) as BookElement;
const stats = () => bookStatsElementSchema.parse({ id: 'st1', kind: 'stats', frame: FRAME }) as BookElement;
function load(elements: BookElement[], selection = elements.slice(0, 1).map((e) => e.id)) {
useStudioStore.getState().load({
version: 1,
title: 'T',
page,
spreads: [{ id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null }],
} as BookDocument);
useStudioStore.getState().select(selection);
}
function draw(live: JourneyStats | null = null) {
return render(
<StudioInspector
spreadIndex={0}
page={page}
stats={live}
path={[]}
source={{ entries: [], photos: [], photoEntries: {} }}
setPageNumbers={() => {}}
t={(k: string) => k}
locale="en"
onOpenPanel={vi.fn()}
/>
);
}
/** The groups the panel shows, top to bottom, by their headings. */
const groups = (c: HTMLElement) =>
[...c.querySelectorAll('.st-section.is-fold > h3 .st-section-label')].map((l) => l.textContent);
/** A group's head, the button that folds it. */
const headOf = (label: string) =>
must(
[...document.querySelectorAll('.st-section-head')].find(
(h) => h.querySelector('.st-section-label')?.textContent === label
)
);
/** Whether a group is open, read off its head. */
function isOpen(label: string): boolean {
return headOf(label).getAttribute('aria-expanded') === 'true';
}
/** Opens a folded group. */
function open(label: string) {
if (!isOpen(label)) fireEvent.click(headOf(label));
}
beforeEach(() => {
useStudioStore.getState().load({ version: 1, title: 'T', page, spreads: [] } as unknown as BookDocument);
});
describe('the order of the groups', () => {
it('FE-STUDIO-INSPORDER-001: puts a text and a photo in the order every kind follows', () => {
load([text()]);
const t = draw();
expect(groups(t.container)).toEqual([
'journey.studio.text',
'journey.studio.position',
'journey.studio.typography',
'journey.studio.colour',
'journey.studio.fx.placement',
'journey.studio.fx.effects',
'journey.studio.arrange',
]);
t.unmount();
load([photo()]);
expect(groups(draw().container)).toEqual([
'journey.studio.crop',
'journey.studio.position',
'journey.studio.look',
'journey.studio.mask',
'journey.studio.edgeStyle',
'journey.studio.photo.edges',
'journey.studio.photo.adjust',
'journey.studio.photo.duotone',
'journey.studio.arrange',
]);
});
it('FE-STUDIO-INSPORDER-002: puts a shape, an icon, a sticker and a line in the same order', () => {
const cases: [BookElement, string[]][] = [
[
shape(),
[
'journey.studio.shape',
'journey.studio.position',
'journey.studio.fill',
'journey.studio.stroke',
'journey.studio.edgeStyle',
'journey.studio.arrange',
],
],
[icon(), ['journey.studio.icon', 'journey.studio.position', 'journey.studio.look', 'journey.studio.arrange']],
[sticker(), ['journey.studio.stickerColours', 'journey.studio.position', 'journey.studio.arrange']],
[
line(),
[
'journey.studio.kind.line',
'journey.studio.position',
'journey.studio.look',
'journey.studio.lineEnds',
'journey.studio.ornamentTitle',
'journey.studio.arrange',
],
],
];
for (const [el, expected] of cases) {
load([el]);
const view = draw();
expect(groups(view.container)).toEqual(expected);
view.unmount();
}
});
it('FE-STUDIO-INSPORDER-003: puts what a map and a summary say before their place, and their look after it', () => {
load([map()]);
const m = draw();
expect(groups(m.container)).toEqual([
'journey.studio.routeMap',
'journey.studio.mapFraming',
'journey.studio.position',
'journey.studio.routeLook',
'journey.studio.mapLayers',
'journey.studio.typography',
'journey.studio.colour',
'journey.studio.arrange',
]);
m.unmount();
load([stats()]);
expect(groups(draw().container)).toEqual([
'journey.studio.metrics',
'journey.studio.layout',
'journey.studio.position',
'journey.studio.typography',
'journey.studio.colour',
'journey.studio.arrange',
]);
});
it('FE-STUDIO-INSPORDER-004: gives several elements their angle without the frame, and arrange open', () => {
load([shape(), shape({ id: 's2', frame: { x: 100, y: 30, w: 20, h: 20 } })], ['s1', 's2']);
const { container } = draw();
expect(groups(container)).toContain('journey.studio.position');
expect(isOpen('journey.studio.position')).toBe(false);
open('journey.studio.position');
// One x for two elements would stack them, so the frame's numbers stay with a single selection.
expect(screen.queryAllByRole('spinbutton', { name: (n) => n.startsWith('X') })).toHaveLength(0);
expect(screen.getAllByRole('spinbutton', { name: (n) => n.startsWith('journey.studio.rotation') })).toHaveLength(1);
expect(isOpen('journey.studio.arrange')).toBe(true);
expect(screen.getByLabelText('journey.studio.showLayers')).toBeTruthy();
});
});
describe('the folded groups', () => {
it('FE-STUDIO-INSPORDER-005: stay folded on a plain element and open on one that uses them', () => {
load([shape()]);
const plain = draw();
expect(['journey.studio.position', 'journey.studio.stroke', 'journey.studio.edgeStyle'].map(isOpen)).toEqual([
false,
false,
false,
]);
plain.unmount();
load([shape({ rotation: 12, stroke: '#141414', strokeWidth: 0.5, edge: 'zigzag' })]);
const used = draw();
expect(['journey.studio.position', 'journey.studio.stroke', 'journey.studio.edgeStyle'].map(isOpen)).toEqual([
true,
true,
true,
]);
used.unmount();
load([text({ transform: 'upper' })]);
const cased = draw();
expect(isOpen('journey.studio.fx.placement')).toBe(true);
expect(isOpen('journey.studio.fx.effects')).toBe(false);
cased.unmount();
load([photo({ mask: 'heart', duotone: { dark: '#1a1a1a', light: '#f5e6c8' } })]);
draw();
expect(isOpen('journey.studio.mask')).toBe(true);
expect(isOpen('journey.studio.photo.duotone')).toBe(true);
expect(isOpen('journey.studio.photo.adjust')).toBe(false);
});
it('FE-STUDIO-INSPORDER-006: open the position of a sticker, which is turned as a matter of course', () => {
load([sticker()]);
draw();
expect(isOpen('journey.studio.position')).toBe(true);
expect(screen.getByRole('button', { name: 'journey.studio.stickerTurnRight' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'journey.studio.stickerFlip' })).toBeTruthy();
// One angle field and one opacity field, not a second pair in a group of the sticker's own.
expect(screen.getAllByRole('spinbutton', { name: (n) => n.startsWith('journey.studio.rotation') })).toHaveLength(1);
});
it('FE-STUDIO-INSPORDER-007: mount afresh for the next selection, remembering nothing', () => {
load([shape(), shape({ id: 's2' })]);
draw();
open('journey.studio.position');
expect(isOpen('journey.studio.position')).toBe(true);
act(() => useStudioStore.getState().select(['s2']));
expect(isOpen('journey.studio.position')).toBe(false);
});
});
describe('several stickers at once', () => {
it('FE-STUDIO-INSPORDER-009: mirror and turn every sticker selected, as the placement group did', () => {
const second = bookStickerElementSchema.parse({
id: 'k2',
kind: 'sticker',
frame: { x: 100, y: 30, w: 40, h: 40 },
stickerId: 'lemon',
}) as BookElement;
load([sticker(), second], ['k1', 'k2']);
draw();
expect(isOpen('journey.studio.position')).toBe(true);
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.stickerFlip' }));
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.stickerTurnRight' }));
const placed = must(useStudioStore.getState().doc).spreads[0]?.elements ?? [];
expect(placed.map((e) => (e.kind === 'sticker' ? e.flipX : null))).toEqual([true, true]);
expect(placed.map((e) => e.rotation)).toEqual([15, 15]);
});
it('FE-STUDIO-INSPORDER-010: offer no mirror when the selection is not all stickers', () => {
load([sticker(), shape()], ['k1', 's1']);
draw();
expect(groups(document.body)).toContain('journey.studio.position');
expect(screen.queryByRole('button', { name: 'journey.studio.stickerFlip' })).toBeNull();
expect(screen.getByRole('button', { name: 'journey.studio.stickerTurnRight' })).toBeTruthy();
});
});
describe('the arrange rows', () => {
it('FE-STUDIO-INSPORDER-011: put each row under its label, in sets that move to a second line whole', () => {
load([shape(), shape({ id: 's2', frame: { x: 100, y: 30, w: 20, h: 20 } })], ['s1', 's2']);
const { container } = draw();
const rows = [...container.querySelectorAll('.st-arrange > .st-line')];
expect(rows).toHaveLength(4);
expect(rows.every((row) => row.classList.contains('is-wide'))).toBe(true);
const sets = [...container.querySelectorAll('.st-arrange-row > .st-arrange-set')];
// The six edges and the two spacings; the four steps of the stack, then grouping and the way to Layers.
expect(sets.map((set) => set.querySelectorAll('button').length)).toEqual([6, 2, 4, 2]);
});
});
describe('the labelled lines', () => {
it('FE-STUDIO-INSPORDER-008: set every label apart from its control, so a long one wraps instead of being cut', () => {
load([text({ binding: { source: 'entry.location', entryId: 7, format: 'dms' } })]);
const { container } = draw();
const lines = [...container.querySelectorAll('.st-line')];
expect(lines.length).toBeGreaterThan(0);
for (const row of lines) {
expect(row.querySelector(':scope > .st-line-label')).not.toBeNull();
expect(row.querySelector(':scope > .st-line-body')).not.toBeNull();
}
for (const field of container.querySelectorAll('.st-num')) {
expect(field.querySelector(':scope > .st-num-label')).not.toBeNull();
expect(field.querySelector(':scope > .st-num-box')).not.toBeNull();
}
});
});
+11 -7
View File
@@ -42,7 +42,11 @@ export type PagePresetId =
*/ */
| 'custom' | 'custom'
function preset(id: PagePresetId, w: number, h: number, labelKey: string): PagePreset { /**
* A format from its page size. Its name is given as `labelKey` where the format
* is listed, where the check of the client's translation keys can read it.
*/
function preset(id: PagePresetId, w: number, h: number, { labelKey }: { labelKey: string }): PagePreset {
return { return {
id, id,
pageWidthMm: w, pageWidthMm: w,
@@ -56,14 +60,14 @@ function preset(id: PagePresetId, w: number, h: number, labelKey: string): PageP
} }
export const PAGE_PRESETS: Record<PagePresetId, PagePreset> = { export const PAGE_PRESETS: Record<PagePresetId, PagePreset> = {
'square-210': preset('square-210', 210, 210, 'journey.studio.formatSquare21'), 'square-210': preset('square-210', 210, 210, { labelKey: 'journey.studio.formatSquare21' }),
'square-300': preset('square-300', 300, 300, 'journey.studio.formatSquare30'), 'square-300': preset('square-300', 300, 300, { labelKey: 'journey.studio.formatSquare30' }),
'a4-landscape': preset('a4-landscape', 297, 210, 'journey.studio.formatA4Landscape'), 'a4-landscape': preset('a4-landscape', 297, 210, { labelKey: 'journey.studio.formatA4Landscape' }),
'a4-portrait': preset('a4-portrait', 210, 297, 'journey.studio.formatA4Portrait'), 'a4-portrait': preset('a4-portrait', 210, 297, { labelKey: 'journey.studio.formatA4Portrait' }),
'a5-landscape': preset('a5-landscape', 210, 148, 'journey.studio.formatA5Landscape'), 'a5-landscape': preset('a5-landscape', 210, 148, { labelKey: 'journey.studio.formatA5Landscape' }),
// The dimensions here are only the starting point the fields open on; the // The dimensions here are only the starting point the fields open on; the
// document keeps whatever the user types. // document keeps whatever the user types.
custom: preset('custom', 210, 210, 'journey.studio.formatCustom'), custom: preset('custom', 210, 210, { labelKey: 'journey.studio.formatCustom' }),
} }
/** /**
@@ -86,8 +86,10 @@ export function GradientStopBar({
const onStopKey = (index: number) => (e: KeyboardEvent<HTMLButtonElement>) => { const onStopKey = (index: number) => (e: KeyboardEvent<HTMLButtonElement>) => {
const stop = paint.stops[index]; const stop = paint.stops[index];
if (!stop) return; if (!stop) return;
if ((e.key === 'Delete' || e.key === 'Backspace') && canRemove) { if (e.key === 'Delete' || e.key === 'Backspace') {
// The stop's key even when the last two cannot go: it never reaches the page to delete the shape.
e.preventDefault(); e.preventDefault();
if (!canRemove) return;
onSelect(Math.max(0, index - 1)); onSelect(Math.max(0, index - 1));
onChange(removeStop(paint, index), true); onChange(removeStop(paint, index), true);
return; return;
@@ -2,7 +2,7 @@ import {
MAX_SPREAD_ELEMENTS, MAX_SPREAD_ELEMENTS,
bookPhotoElementSchema, bookPhotoElementSchema,
spreadPaint, spreadPaint,
withSpreadPaint, withSpreadPaintByHand,
type BookDocument, type BookDocument,
type BookElement, type BookElement,
type BookPageSetup, type BookPageSetup,
@@ -77,20 +77,27 @@ export function withoutBackgroundPhoto(spread: BookSpread, page: BookPageSetup):
const isCover = (spread: BookSpread) => spread.role === 'cover' || spread.role === 'back'; const isCover = (spread: BookSpread) => spread.role === 'cover' || spread.role === 'back';
/** /**
* Every page given the background of page `from`: its paint and colour, * Every page given the background of page `from`: its paint and colour, and
* nothing else. "Every page" is every page of its kind: an inside page * whether it follows the theme, nothing else. "Every page" is every page of
* gives its background to the inside of the book, a cover to the covers, * its kind: an inside page gives its background to the inside of the book, a
* since a cover is designed on its own and an inside page's paper is rarely * cover to the covers, since a cover is designed on its own and an inside
* what the cover wants. * page's paper is rarely what the cover wants. A page that follows the
* theme's paper gives that link along; one painted by hand takes the others'
* away, so the next theme applied leaves them all as they were made.
*/ */
export function backgroundToAll(doc: BookDocument, from: number): BookDocument { export function backgroundToAll(doc: BookDocument, from: number): BookDocument {
const source = doc.spreads[from]; const source = doc.spreads[from];
if (!source) return doc; if (!source) return doc;
const paint = spreadPaint(source); const paint = spreadPaint(source);
const kind = isCover(source); const kind = isCover(source);
const link = source.backgroundToken;
const given = (sp: BookSpread): BookSpread => {
const painted = withSpreadPaintByHand(sp, paint);
return link ? { ...painted, backgroundToken: link } : painted;
};
return { return {
...doc, ...doc,
spreads: doc.spreads.map((sp, i) => (i === from || isCover(sp) !== kind ? sp : withSpreadPaint(sp, paint))), spreads: doc.spreads.map((sp, i) => (i === from || isCover(sp) !== kind ? sp : given(sp))),
}; };
} }
@@ -16,6 +16,7 @@ import {
import { FlipHorizontal2, Printer, RotateCcw, TriangleAlert } from 'lucide-react'; import { FlipHorizontal2, Printer, RotateCcw, TriangleAlert } from 'lucide-react';
import '../../../styles/studio-photo.css'; import '../../../styles/studio-photo.css';
import { photoEdgesInUse } from '../inspector/inspectorModel';
import { Line, NumField, Section, Switch } from '../StudioControls'; import { Line, NumField, Section, Switch } from '../StudioControls';
import { Swatches } from '../StudioSwatches'; import { Swatches } from '../StudioSwatches';
import { StudioSlider } from './StudioSlider'; import { StudioSlider } from './StudioSlider';
@@ -264,7 +265,7 @@ export function PhotoLookSections(props: RowProps) {
const raster = t('journey.studio.photo.rasterHint'); const raster = t('journey.studio.photo.rasterHint');
return ( return (
<> <>
<Section label={t('journey.studio.photo.edges')} defaultOpen={!!(look.border || look.shadow || look.cutout)}> <Section label={t('journey.studio.photo.edges')} defaultOpen={photoEdgesInUse(el)}>
<EdgeRow <EdgeRow
label={t('journey.studio.photo.border')} label={t('journey.studio.photo.border')}
value={look.border} value={look.border}
+13 -5
View File
@@ -68,6 +68,14 @@ export function matchingEntries(source: JourneySource, query: string): JourneySo
.some(v => v && v.toLowerCase().includes(q))) .some(v => v && v.toLowerCase().includes(q)))
} }
/** The four answers, each named as `emptyKey` so the check of the client's translation keys can read it. */
const EMPTY = {
none: { emptyKey: 'journey.studio.noPhotos' },
unmatched: { emptyKey: 'journey.studio.noMatches' },
entry: { emptyKey: 'journey.studio.noEntryPhotos' },
loose: { emptyKey: 'journey.studio.noLoosePhotos' },
} as const
/** /**
* Why the grid is empty, which is four different answers. * Why the grid is empty, which is four different answers.
* *
@@ -75,9 +83,9 @@ export function matchingEntries(source: JourneySource, query: string): JourneySo
* on a day that has none, reads as the pictures having gone. * on a day that has none, reads as the pictures having gone.
*/ */
export function emptyKeyFor(source: JourneySource, filter: PhotoFilter, admitted: Photo[]): string { export function emptyKeyFor(source: JourneySource, filter: PhotoFilter, admitted: Photo[]): string {
if (!source.photos.length) return 'journey.studio.noPhotos' if (!source.photos.length) return EMPTY.none.emptyKey
if (admitted.length) return 'journey.studio.noMatches' if (admitted.length) return EMPTY.unmatched.emptyKey
if (filter.kind === 'entry') return 'journey.studio.noEntryPhotos' if (filter.kind === 'entry') return EMPTY.entry.emptyKey
if (filter.kind === 'loose') return 'journey.studio.noLoosePhotos' if (filter.kind === 'loose') return EMPTY.loose.emptyKey
return 'journey.studio.noMatches' return EMPTY.unmatched.emptyKey
} }
+6 -87
View File
@@ -1,4 +1,4 @@
import type { BookShapeId } from '@trek/shared' import { BOOK_FRAME_SHAPES, BOOK_SHAPE_GROUPS, type BookShapeId } from '@trek/shared'
import { K, circle, polygon, r2, roundRect, scallop, star } from './shapePrimitives' import { K, circle, polygon, r2, roundRect, scallop, star } from './shapePrimitives'
import { MORE_SHAPE_PATHS } from './shapes-more' import { MORE_SHAPE_PATHS } from './shapes-more'
@@ -323,91 +323,10 @@ export function unitPath(path: string): string {
*/ */
export const HOLED_SHAPES = new Set<BookShapeId>(['gear', 'postmark', 'label-tab']) export const HOLED_SHAPES = new Set<BookShapeId>(['gear', 'postmark', 'label-tab'])
export interface ShapeGroup {
/** i18n key under `journey.studio.shapeGroup`. */
id: string
shapes: BookShapeId[]
}
/** /**
* How the panel is divided. * How the panel is divided and which shapes it offers as picture frames: the
* * one list in @trek/shared (book-shape-catalogue.ts), which the assistant's
* Grouped the way you go looking for them — "I want an arrow", "I want * asset listing reads too.
* something round and soft" — rather than by how they are built. Sixty
* unlabelled tiles in one grid is a wall; six groups of ten is a menu.
*/ */
export const SHAPE_GROUPS: ShapeGroup[] = [ export const SHAPE_GROUPS = BOOK_SHAPE_GROUPS
{ export const FRAME_SHAPES = BOOK_FRAME_SHAPES
id: 'basic',
shapes: ['rect', 'squircle', 'ellipse', 'capsule', 'triangle', 'triangle-down',
'diamond', 'parallelogram', 'trapezoid', 'arch', 'half-circle', 'quarter-circle'],
},
{
id: 'polygons',
shapes: ['pentagon', 'hexagon', 'hexagon-flat', 'heptagon', 'octagon'],
},
{
id: 'stars',
shapes: ['star-4', 'star-5', 'star-6', 'star-8', 'star-12', 'burst', 'seal', 'sparkle',
'star-rounded', 'star-twinkle'],
},
{
id: 'arrows',
shapes: ['arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'arrow-both',
'chevron-right', 'chevron-left', 'arrow-bent', 'arrow-curve', 'arrow-circle', 'arrow-notched', 'arrow-thin'],
},
{
id: 'speech',
shapes: ['bubble-round', 'bubble-square', 'bubble-oval', 'bubble-think',
'bubble-shout', 'bubble-side', 'bubble-capsule'],
},
{
id: 'travel',
shapes: ['pin', 'compass', 'mountain', 'sun', 'moon', 'cloud', 'cloud-puffy',
'drop', 'wave', 'leaf', 'shield', 'ticket'],
},
{
id: 'decor',
shapes: ['heart', 'flower-5', 'flower-6', 'cross', 'plus', 'gear',
'blob-1', 'blob-2', 'blob-3', 'blob-4', 'heart-round', 'heart-pair', 'flower-8'],
},
{
id: 'banners',
shapes: ['banner-ribbon', 'banner-pennant', 'banner-bookmark', 'banner-flag',
'ribbon-ends', 'ribbon-arc', 'wavy-band', 'torn-strip'],
},
// The second shelf (shapes-more.ts).
{ id: 'arches', shapes: ['arch-tall', 'arch-pointed', 'arch-double', 'arch-half', 'arch-rainbow'] },
{
id: 'cutouts',
shapes: ['leaf-palm', 'leaf-oak', 'leaf-long', 'leaf-monstera', 'coral-1', 'coral-2', 'seaweed',
'star-organic', 'sun-cutout', 'organic-1', 'organic-2', 'organic-3'],
},
{
id: 'stamps',
shapes: ['stamp-rect', 'stamp-circle', 'postmark', 'rosette', 'scallop-circle', 'badge-shield',
'label-tab', 'ticket-stub', 'sunburst-12', 'sunburst-24'],
},
{ id: 'frames', shapes: ['frame-notched', 'frame-chamfer', 'frame-cushion'] },
{ id: 'ornaments', shapes: ['flourish-scroll', 'laurel', 'fleuron', 'corner-flourish'] },
]
/**
* The shapes offered as picture frames.
*
* A subset, and the subset is the point: a photograph cut to a speech bubble's
* tail or to a hairline is a photograph you cannot read. What is here holds an
* image at book size — broad, mostly convex, no thin limbs.
*/
export const FRAME_SHAPES: BookShapeId[] = [
'rect', 'squircle', 'ellipse', 'capsule', 'arch', 'half-circle',
'triangle', 'diamond', 'pentagon', 'hexagon', 'octagon',
'heart', 'star-5', 'star-6', 'seal', 'flower-5', 'flower-6',
'blob-1', 'blob-2', 'blob-3', 'blob-4',
'shield', 'drop', 'leaf', 'pin', 'banner-pennant', 'banner-bookmark',
// The second shelf: arches, stamps and the frames cut for photographs.
'arch-tall', 'arch-pointed', 'arch-double', 'arch-half', 'stamp-rect', 'stamp-circle',
'scallop-circle', 'badge-shield', 'ticket-stub', 'heart-round', 'star-rounded', 'flower-8',
'frame-notched', 'frame-chamfer', 'frame-cushion', 'organic-1', 'organic-2', 'organic-3',
'star-organic', 'sun-cutout', 'bubble-side', 'bubble-capsule',
]
@@ -0,0 +1,243 @@
import type { BookPageSetup } from '@trek/shared';
import { ImagePlus, Upload } from 'lucide-react';
import { useId, useState } from 'react';
import { useStudioStore } from '../../../store/studioStore';
import { photoSrc } from '../bookRender';
import { emptyKeyFor, matchingEntries, matchingPhotos, photosFor, type PhotoFilter } from '../photoFilter';
import { StudioContentFilter } from '../StudioContentFilter';
import { PanelHead } from '../StudioPanelHead';
import { StudioSearch } from '../StudioSearch';
import type { JourneySource, StudioT } from '../StudioSidebar';
import { StudioTabPanel, StudioTabs } from '../StudioTabs';
import type { StudioUploader } from '../studioUpload';
import { photoDrop, type DropPlace } from './contentDrops';
import { StudioEntryCard } from './StudioEntryCard';
import { useContentUpload } from './useContentUpload';
/**
* The journey's own material.
*
* This is what separates a book maker from a drawing program: the pictures and
* the words are already written, and the job is putting them on pages. Clicking
* an item drops it on the current spread, centred, at a sensible size.
*
* Pictures can also come in here. A book is where you notice the one that
* never made it into the journal, and going back to the journey to add it was
* the one round trip Studio still asked for. What comes in goes through the
* journey store like any other upload, so the journal sees it the moment the
* panel does.
*/
type Tab = 'photos' | 'text';
/**
* The tooltip of an upload cell: its words in full, then what it does. The
* cell is a square a third of the panel wide, so a long label ("Μεταφόρτωση
* φωτογραφιών", "Uploading 12 of 30") is held to two lines there.
*/
const cellTitle = (label: string, hint: string) => `${label}\n${hint}`;
export function StudioContentPanel({
source,
page,
t,
locale,
canEdit,
onUpload,
providers = [],
onBrowseProvider,
}: {
source: JourneySource;
page: BookPageSetup;
t: StudioT;
locale: string;
canEdit: boolean;
onUpload: StudioUploader;
providers?: { id: string; name: string }[];
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>;
}) {
const [tab, setTab] = useState<Tab>('photos');
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<PhotoFilter>({ kind: 'all' });
const tabsId = useId();
const entries = matchingEntries(source, query);
const admitted = photosFor(source, filter);
const photos = matchingPhotos(admitted, source, query);
const emptyKey = emptyKeyFor(source, filter, admitted);
const addElement = useStudioStore((s) => s.addElement);
const active = useStudioStore((s) => s.activeSpread);
const spread = useStudioStore((s) => s.doc?.spreads[s.activeSpread]);
const place: DropPlace = { page, single: !!spread && spread.role !== 'inner' };
const content = useContentUpload({
filter,
showArrived: (photoIds) => {
setFilter({ kind: 'recent', photoIds });
setQuery('');
},
onUpload,
onBrowseProvider,
enabled: canEdit && tab === 'photos',
t,
});
const { upload, fileInput } = content;
const uploadLabel = upload
? t('journey.studio.uploading', { done: upload.done, total: upload.total })
: t('journey.studio.uploadPhotos');
return (
<>
<PanelHead label={t('journey.studio.content')} />
<StudioSearch
value={query}
onChange={setQuery}
label={t('journey.studio.searchContent')}
clearLabel={t('common.clear')}
/>
<StudioTabs<Tab>
idBase={tabsId}
label={t('journey.studio.content')}
value={tab}
onChange={setTab}
tabs={[
{ id: 'photos', label: t('journey.studio.photos'), count: photos.length },
{ id: 'text', label: t('journey.studio.entries'), count: entries.length },
]}
/>
{/*
Which entry, as a row of its own under the tabs rather than a control
inside the search box: the search answers "which words" and this
answers "which day", and the two combine.
*/}
{tab === 'photos' && (
<StudioContentFilter
source={source}
filter={filter}
setFilter={setFilter}
admitted={admitted.length}
pageEntryId={spread?.entryId ?? null}
t={t}
locale={locale}
/>
)}
<StudioTabPanel
idBase={tabsId}
value={tab}
className={`st-panel-scroll ${content.dropOver ? 'is-drop-over' : ''}`}
{...content.dropZone}
>
{tab === 'photos' ? (
<>
{canEdit && (
<p className="st-hint st-upload-where">
{t(filter.kind === 'entry' ? 'journey.studio.uploadToEntry' : 'journey.studio.uploadToGallery')}
</p>
)}
<div className="st-photo-grid">
{/*
The first cell rather than a button above the grid: it is the
size and shape of what it produces, and it stays where the eye
already is. Disabled, not hidden, while a batch is on its way, so
the count has somewhere to be read.
*/}
{canEdit && (
<button
type="button"
className="st-photo-cell is-upload"
onClick={() => fileInput.current?.click()}
disabled={!!upload}
title={cellTitle(uploadLabel, t('journey.studio.uploadHint'))}
>
<Upload size={18} strokeWidth={1.6} aria-hidden />
<span>{uploadLabel}</span>
</button>
)}
{canEdit &&
onBrowseProvider &&
providers.map((p) => (
<button
type="button"
key={p.id}
className="st-photo-cell is-upload"
onClick={() => {
void content.pickFromProvider(p.id);
}}
disabled={!!upload}
title={cellTitle(
t('journey.studio.fromProvider', { name: p.name }),
t('journey.studio.fromProviderHint', { name: p.name })
)}
>
<ImagePlus size={18} strokeWidth={1.6} aria-hidden />
<span>{t('journey.studio.fromProvider', { name: p.name })}</span>
</button>
))}
{photos.map((p) => (
<button
type="button"
key={p.photoId}
className="st-photo-cell"
onClick={() => addElement(active, photoDrop(place, p.photoId))}
title={t('journey.studio.addToPage')}
aria-label={
p.caption ? `${t('journey.studio.addToPage')}: ${p.caption}` : t('journey.studio.addToPage')
}
draggable
onDragStart={(e) => {
// HTML5 drag is the right tool for one item from a list onto a target, and it gives
// the thumbnail as the drag image for free; the canvas uses pointer events instead.
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
</button>
))}
{!photos.length && <p className="st-hint">{t(emptyKey)}</p>}
</div>
{canEdit && (
<input
ref={fileInput}
type="file"
multiple
accept="image/*,.heic,.heif"
hidden
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
// Cleared straight away, as the spread import does, so the same pictures can be chosen twice.
e.target.value = '';
void content.send(files);
}}
/>
)}
</>
) : (
<div className="st-entries">
{entries.map((e) => (
<StudioEntryCard
key={e.id}
entry={e}
place={place}
locale={locale}
t={t}
onAdd={(el) => addElement(active, el)}
onPhotos={(entryId) => {
setFilter({ kind: 'entry', id: entryId });
setTab('photos');
}}
/>
))}
{!entries.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
</div>
)}
</StudioTabPanel>
</>
);
}
@@ -0,0 +1,157 @@
import type { BookElement } from '@trek/shared';
import { bookCoordValue, formatBookCoords, formatBookDate } from '@trek/shared';
import { Camera } from 'lucide-react';
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../../pages/journeyDetail/JourneyDetailPage.constants';
import { formatDate } from '../../../utils/formatters';
import type { JourneySource } from '../StudioSidebar';
import { markDrop, prosConsDrop, textDrop, type DropPlace } from './contentDrops';
type Entry = JourneySource['entries'][number];
/** One chip of an entry: what it says, and the element it places, built when it is pressed. */
interface EntryChip {
key: string;
label: string;
make: () => BookElement;
/** How many pieces it places, for the pros and cons. */
count?: number;
}
/**
* The pieces of an entry that can go on a page, in the order the card offers
* them: its words (title, story, place), its day and its point, set the way the
* rest of the book sets them (the long date, coordinates in degrees), its mood
* and weather, and its pros and cons. A piece the entry has not got is not
* offered. Each element is built only when its chip is pressed, so a card
* drawn twice mints no ids.
*/
function entryChips(entry: Entry, place: DropPlace, locale: string, t: (k: string) => string): EntryChip[] {
const { id, title, story, location, date, lat, lng, mood, weather, pros, cons } = entry;
// The short marks (a date, a point) land on a short line rather than across the whole column.
const markWidth = place.page.pageWidth * 0.36;
const bold = { size: 8, weight: 700 } as const;
const chips: EntryChip[] = [];
if (title)
chips.push({
key: 'title',
label: t('journey.studio.addTitle'),
make: () => textDrop(place, title, { size: 22, weight: 700 }, { entryId: id, source: 'entry.title' }),
});
if (story)
chips.push({
key: 'story',
label: t('journey.studio.addStory'),
make: () => textDrop(place, story, { size: 10, weight: 400 }, { entryId: id, source: 'entry.story' }),
});
if (location)
chips.push({
key: 'place',
label: t('journey.studio.addPlace'),
make: () => textDrop(place, location, bold, { entryId: id, source: 'entry.location' }),
});
if (date)
chips.push({
key: 'date',
label: t('journey.studio.dateMark'),
make: () =>
textDrop(
place,
formatBookDate(date, locale),
bold,
{ entryId: id, source: 'entry.date', value: date },
markWidth
),
});
if (lat != null && lng != null)
chips.push({
key: 'coords',
label: t('journey.studio.coordsMark'),
make: () =>
textDrop(
place,
formatBookCoords(lat, lng, 'dms'),
bold,
{ entryId: id, source: 'entry.location', format: 'dms', value: bookCoordValue(lat, lng) },
markWidth
),
});
const moodMark = mood ? MOOD_CONFIG[mood] : undefined;
if (mood && moodMark) {
const label = t(moodMark.label);
chips.push({ key: 'mood', label, make: () => markDrop(place, 'mood', mood, label) });
}
const weatherMark = weather ? WEATHER_CONFIG[weather] : undefined;
if (weather && weatherMark) {
const label = t(weatherMark.label);
chips.push({ key: 'weather', label, make: () => markDrop(place, 'weather', weather, label) });
}
if (pros.length > 0 || cons.length > 0)
chips.push({
key: 'proscons',
label: t('journey.studio.addProsCons'),
count: pros.length + cons.length,
make: () => prosConsDrop(place, { pros, cons }, { pro: t('journey.editor.pros'), con: t('journey.editor.cons') }),
});
return chips;
}
/**
* One entry of the journal in Content > Entries: its name, its day and place,
* and a chip for every piece of it that can go on a page.
*
* Each chip shows what it places rather than the field it comes from ("Sunny"
* tells you what you are about to place; "Weather" does not), and every piece
* arrives still bound to the entry. The last chip is not a drop: it opens the
* entry's photos, because the day is how anyone thinks of its pictures.
*/
export function StudioEntryCard({
entry,
place,
locale,
t,
onAdd,
onPhotos,
}: {
entry: Entry;
place: DropPlace;
locale: string;
t: (k: string) => string;
onAdd: (el: BookElement) => void;
/** Opens the photos tab on this entry's pictures. */
onPhotos: (entryId: number) => void;
}) {
const { id, title, location, date, photoIds } = entry;
return (
<div className="st-entry">
<div className="st-entry-head">{title || location || t('journey.studio.untitled')}</div>
{(date || location) && (
<div className="st-entry-meta">
{/* Two facts, two badges. Joined by a dot they read as one string. The date follows the app's
language, like every other date in TREK (utils/formatters.ts). */}
{date && <span className="st-badge">{formatDate(date, locale) ?? date}</span>}
{location && (
<span className="st-badge is-quiet" title={location}>
{location}
</span>
)}
</div>
)}
<div className="st-row st-entry-chips">
{entryChips(entry, place, locale, t).map((chip) => (
<button type="button" key={chip.key} className="st-chip" onClick={() => onAdd(chip.make())}>
{chip.label}
{chip.count != null && <span className="st-chip-count">{chip.count}</span>}
</button>
))}
{photoIds.length > 0 && (
<button type="button" className="st-chip" onClick={() => onPhotos(id)}>
<Camera size={12} aria-hidden />
{t('journey.studio.entryPhotos')}
<span className="st-chip-count">{photoIds.length}</span>
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,98 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from '../iconLibrary';
import { Shelf } from '../StudioPanelHead';
import { StudioSearch } from '../StudioSearch';
/**
* The icon library, as the Icons tab of the Elements panel: a shelf of icons
* for travel, then all fourteen hundred, and a search over their names.
*
* Fourteen hundred icons is fourteen hundred React components, each a
* forwardRef around several SVG nodes, and rendering them all costs about a
* second of blocked main thread on the first click of the tab, for a grid
* whose first rows are all anybody sees. So it grows as it is scrolled, and a
* search resets it: the results of a search are short, and starting them
* part-drawn would be wrong.
*/
/** How many icons the grid draws at a time. Two screens' worth at three across. */
const ICON_PAGE = 120;
function IconCell({ name, onPick }: { name: string; onPick: (name: string) => void }) {
const Icon = iconComponent(name);
const label = iconLabel(name);
return (
<button
type="button"
className="st-shape-btn is-glyph"
onClick={() => onPick(name)}
aria-label={name}
title={label}
>
<Icon strokeWidth={1.7} />
</button>
);
}
export function StudioIconShelf({ onPick, t }: { onPick: (name: string) => void; t: (k: string) => string }) {
const [query, setQuery] = useState('');
const [limit, setLimit] = useState(ICON_PAGE);
const tail = useRef<HTMLDivElement>(null);
const matched = useMemo(() => searchIcons(query), [query]);
const shown = useMemo(() => matched.slice(0, limit), [matched, limit]);
/*
* Grow the grid when its foot comes into view. An observer rather than a
* scroll handler: the panel is the scroller, the foot is the thing that
* matters, and asking the browser to say when they meet costs nothing.
*/
useEffect(() => {
const foot = tail.current;
if (!foot) return;
const io = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) setLimit((n) => n + ICON_PAGE);
});
io.observe(foot);
return () => io.disconnect();
}, [shown.length, matched.length]);
const search = (value: string) => {
setQuery(value);
setLimit(ICON_PAGE);
};
return (
<>
<StudioSearch
value={query}
onChange={search}
label={t('journey.studio.searchIcons')}
clearLabel={t('common.clear')}
count={matched.length}
/>
<div className="st-panel-scroll">
{/* The shelf, until somebody searches. */}
{!query && (
<Shelf label={t('journey.studio.iconsForTravel')}>
<div className="st-shape-grid">
{FEATURED_ICONS.map((name) => (
<IconCell key={name} name={name} onPick={onPick} />
))}
</div>
</Shelf>
)}
<Shelf label={query ? undefined : t('journey.studio.iconsAll')}>
<div className="st-shape-grid">
{shown.map((name) => (
<IconCell key={name} name={name} onPick={onPick} />
))}
</div>
{/* What the observer above watches for. */}
{shown.length < matched.length && <div ref={tail} className="st-shelf-tail" />}
{!matched.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
</Shelf>
</div>
</>
);
}
@@ -0,0 +1,227 @@
import type { BookPageSetup } from '@trek/shared';
import { ChevronDown, ChevronUp, Copy, FileDown, Plus, Trash2 } from 'lucide-react';
import { useRef, useState, type ChangeEvent } from 'react';
import { useElementSize } from '../../../hooks/useElementSize';
import { useStudioStore } from '../../../store/studioStore';
import { folioLabel, foliosOf } from '../bookSheets';
import { MAX_SPREAD_FILE_BYTES, importSpread } from '../spreadFile';
import { SpreadThumb } from '../SpreadThumb';
import { PanelHead } from '../StudioPanelHead';
/**
* The Pages section of the rail: the book's pages in order, the actions on
* each, and the two ways to a new one.
*
* Page thumbnails are drawn at a `scale()` rather than laid out by CSS (the
* sheet inside them is sized in millimetres), so they need an actual pixel
* width, and it is **measured** rather than derived. Deriving it was tried
* twice and was wrong twice: the scrollbar appears only once the list is long
* enough and takes a width that depends on the platform, and the arithmetic
* that ignored it produced a card overhanging its own selection outline.
* Measuring the row the cards sit in gets every contribution at once.
*/
/** The narrowest a page preview is drawn. */
const MIN_PREVIEW_W = 120;
/** The button's border and padding, which the row's width still includes. */
const THUMB_CHROME = (2 + 3) * 2;
export function StudioPagesPanel({
page,
pxPerMm,
bookView,
t,
}: {
page: BookPageSetup;
pxPerMm: number;
bookView: boolean;
t: (k: string) => string;
}) {
const doc = useStudioStore((s) => s.doc);
const active = useStudioStore((s) => s.activeSpread);
// The page a chat card points at, outlined here (journey-studio.md 4.2).
const hinted = useStudioStore((s) => s.hintSpread);
const setActive = useStudioStore((s) => s.setActiveSpread);
const addSpread = useStudioStore((s) => s.addSpread);
const insertSpread = useStudioStore((s) => s.insertSpread);
const duplicateSpread = useStudioStore((s) => s.duplicateSpread);
const removeSpread = useStudioStore((s) => s.removeSpread);
const moveSpread = useStudioStore((s) => s.moveSpread);
const canEditSpread = useStudioStore((s) => s.canEditSpread);
const spreads = doc?.spreads ?? [];
const list = useElementSize<HTMLDivElement>();
const thumbWidth = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME);
/** Where a new spread would land: after the last inner one. */
const lastInner = spreads.reduce((last, sp, i) => (sp.role === 'inner' ? i : last), -1);
const fileInput = useRef<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(null);
const importFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Cleared straight away so choosing the same file twice in a row still fires a change event the second time.
e.target.value = '';
if (!file) return;
setImportError(null);
// Checked before reading rather than after: the point of a limit on a file someone else wrote is not to parse it.
if (file.size > MAX_SPREAD_FILE_BYTES) {
setImportError(t('journey.studio.importSpreadFailed'));
return;
}
try {
const spread = importSpread(JSON.parse(await file.text()), page);
if (!spread) {
setImportError(t('journey.studio.importSpreadFailed'));
return;
}
insertSpread(lastInner, spread);
} catch {
setImportError(t('journey.studio.importSpreadFailed'));
}
};
return (
<>
<PanelHead label={t('journey.studio.pages')} count={spreads.length} />
<div className="st-panel-scroll">
<div className="st-thumbs" ref={list.ref}>
{spreads.map((sp, i) => {
const editable = canEditSpread(i);
const folios = foliosOf(spreads, i, page.pageNumbers.startAt);
return (
<div className="st-thumb-row" key={sp.id}>
<button
type="button"
className={`st-thumb ${i === active ? 'is-active' : ''} ${sp.id === hinted ? 'is-hinted' : ''}`}
aria-current={i === active ? 'page' : undefined}
onClick={() => setActive(i)}
>
<SpreadThumb
spread={sp}
page={page}
width={thumbWidth}
pxPerMm={pxPerMm}
folios={folios}
fold={bookView}
className="st-thumb-sheet"
/>
<span className="st-thumb-label">
{sp.role === 'cover'
? t('journey.studio.cover')
: sp.role === 'back'
? t('journey.studio.backCover')
: folioLabel(folios)}
</span>
</button>
{/*
The actions sit on the thumbnail rather than in a menu bar of
their own: what you are acting on is the page you are pointing
at. The cover and the back cover have none: a book has exactly
one of each, and moving or deleting them is not a thing anyone
means to do.
*/}
{editable && (
<div className="st-thumb-actions">
<button
type="button"
onClick={() => moveSpread(i, -1)}
disabled={!canEditSpread(i - 1)}
title={t('journey.studio.movePageUp')}
aria-label={t('journey.studio.movePageUp')}
>
<ChevronUp size={13} />
</button>
<button
type="button"
onClick={() => moveSpread(i, 1)}
disabled={!canEditSpread(i + 1)}
title={t('journey.studio.movePageDown')}
aria-label={t('journey.studio.movePageDown')}
>
<ChevronDown size={13} />
</button>
<button
type="button"
onClick={() => duplicateSpread(i)}
title={t('journey.studio.duplicatePage')}
aria-label={t('journey.studio.duplicatePage')}
>
<Copy size={12} />
</button>
<button
type="button"
className="is-danger"
onClick={() => removeSpread(i)}
title={t('journey.studio.deletePage')}
aria-label={t('journey.studio.deletePage')}
>
<Trash2 size={12} />
</button>
</div>
)}
{/* Insert between two spreads, where the new one will go: a single
"add" at the end cannot express "another page here". */}
{editable && (
<button
type="button"
className="st-thumb-insert"
onClick={() => addSpread(i)}
title={t('journey.studio.addPageAfter')}
aria-label={t('journey.studio.addPageAfter')}
>
<Plus size={12} />
</button>
)}
</div>
);
})}
</div>
{/*
Two ways to get a new page: an empty one, or somebody else's design.
Side by side because they answer the same question; the import is not
a setting buried in a menu, it is the other half of "add".
*/}
<div className="st-add-row">
<button
type="button"
className="st-add-page"
onClick={() => addSpread(lastInner)}
title={t('journey.studio.addPage')}
aria-label={t('journey.studio.addPage')}
>
<Plus size={14} aria-hidden />
{t('journey.studio.addPageShort')}
</button>
<button
type="button"
className="st-add-page is-import"
onClick={() => fileInput.current?.click()}
title={t('journey.studio.importSpreadHint')}
>
<FileDown size={14} aria-hidden />
{t('journey.studio.importSpread')}
</button>
</div>
<input
ref={fileInput}
type="file"
accept=".json,application/json"
hidden
onChange={(e) => {
void importFile(e);
}}
/>
{importError && (
<p className="st-add-error" role="alert">
{importError}
</p>
)}
</div>
</>
);
}
@@ -0,0 +1,171 @@
import type { BookDocument } from '@trek/shared';
import { normalizeBookDocument } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { useStudioStore } from '../../../store/studioStore';
import { StudioSidebar, type JourneySource } from '../StudioSidebar';
import type { StudioUploader } from '../studioUpload';
import { RAIL_SECTIONS, type RailSection } from './railSections';
/**
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-006: the rail down the side of Studio
* and the panel it opens. Every section is a tab that opens its own panel
* under one head, the keyboard reaches every tab, a long name wraps inside
* the rail, and the shell can hold the open section so the inspector can open
* one from the other side.
*/
const SOURCE: JourneySource = { entries: [], photos: [], photoEntries: {} };
const NO_UPLOAD: StudioUploader = async () => ({ photoIds: [], failed: 0, skippedVideos: 0 });
/** The value, or a failed test saying it was missing. */
function must<T>(value: T | null | undefined): T {
if (value == null) throw new Error('expected a value');
return value;
}
const book = (): BookDocument =>
normalizeBookDocument({
version: 1,
title: 'Lisbon',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads: [{ id: 'sp1', role: 'inner', entryId: null, background: null, elements: [], parked: [] }],
});
function draw(over: { section?: RailSection; onSection?: (s: RailSection) => void } = {}) {
const doc = must(useStudioStore.getState().doc);
return render(
<StudioSidebar
page={doc.page}
pxPerMm={96 / 25.4}
bookView={false}
source={SOURCE}
stats={null}
path={[]}
t={(k: string) => k}
locale="en"
canEdit
onUpload={NO_UPLOAD}
onToggleStop={async () => true}
section={over.section}
onSection={over.onSection}
/>
);
}
/** The head of the panel that is open: the name every panel opens on. */
const panelTitle = () => must(document.querySelector('.st-side .st-panel-head .st-what-label')).textContent;
/** The name each section's panel opens on. */
const TITLES: Record<RailSection, string> = {
pages: 'journey.studio.pages',
content: 'journey.studio.content',
templates: 'journey.studio.templates',
elements: 'journey.studio.elements',
travel: 'journey.studio.travel',
theme: 'studio.theme.panel',
layers: 'journey.studio.layers',
};
beforeEach(() => {
useStudioStore.getState().load(book());
});
describe('the rail', () => {
it('FE-STUDIO-RAIL-001: lists the sections in the order a book is made, the theme and layers set apart', () => {
const { container } = draw();
const rail = screen.getByRole('tablist', { name: 'journey.studio.sections' });
expect(rail.getAttribute('aria-orientation')).toBe('vertical');
const tabs = within(rail).getAllByRole('tab');
expect(tabs.map((t) => t.getAttribute('aria-label'))).toEqual(RAIL_SECTIONS.map((s) => s.labelKey));
expect(RAIL_SECTIONS.map((s) => s.id)).toEqual([
'pages',
'content',
'templates',
'elements',
'travel',
'theme',
'layers',
]);
// The rule sits right before the theme, and stickers are no longer a section of their own.
const sep = must(container.querySelector('.st-rail-sep'));
expect(sep.nextElementSibling?.getAttribute('aria-label')).toBe('studio.theme.panel');
expect(tabs.some((t) => t.getAttribute('aria-label') === 'journey.studio.stickers')).toBe(false);
});
it('FE-STUDIO-RAIL-002: opens on Pages, and every tab opens its own panel under its own head', () => {
draw();
expect(screen.getByRole('tab', { name: 'journey.studio.pages' }).getAttribute('aria-selected')).toBe('true');
expect(panelTitle()).toBe(TITLES.pages);
for (const entry of RAIL_SECTIONS) {
const tab = screen.getByRole('tab', { name: entry.labelKey });
fireEvent.click(tab);
expect(tab.getAttribute('aria-selected')).toBe('true');
const panel = must(document.querySelector('.st-side'));
expect(panel.getAttribute('role')).toBe('tabpanel');
expect(panel.getAttribute('aria-labelledby')).toBe(tab.id);
expect(panelTitle()).toBe(TITLES[entry.id]);
}
});
it('FE-STUDIO-RAIL-003: keeps one tab in the Tab order and reaches every tab with the arrows', () => {
draw();
const tabs = screen.getAllByRole('tab').filter((t) => t.classList.contains('st-rail-btn'));
expect(tabs.map((t) => t.tabIndex)).toEqual([0, -1, -1, -1, -1, -1, -1]);
const first = must(tabs[0]);
first.focus();
const seen = [document.activeElement];
for (let i = 1; i < tabs.length; i++) {
fireEvent.keyDown(must(document.activeElement), { key: 'ArrowDown' });
seen.push(document.activeElement);
}
expect(seen).toEqual(tabs);
// Down from the last wraps to the first; Home and End jump; arrowing opens nothing.
fireEvent.keyDown(must(document.activeElement), { key: 'ArrowDown' });
expect(document.activeElement).toBe(first);
fireEvent.keyDown(first, { key: 'End' });
expect(document.activeElement).toBe(tabs[tabs.length - 1]);
fireEvent.keyDown(must(document.activeElement), { key: 'Home' });
expect(document.activeElement).toBe(first);
fireEvent.keyDown(first, { key: 'ArrowUp' });
expect(document.activeElement).toBe(tabs[tabs.length - 1]);
expect(panelTitle()).toBe(TITLES.pages);
});
it('FE-STUDIO-RAIL-004: carries each name whole in its title, in a label that may wrap', () => {
const { container } = draw();
for (const tab of screen.getAllByRole('tab').filter((t) => t.classList.contains('st-rail-btn'))) {
const label = must(tab.querySelector('.st-rail-label'));
expect(tab.getAttribute('title')).toBe(label.textContent);
}
expect(container.querySelectorAll('.st-rail-label')).toHaveLength(RAIL_SECTIONS.length);
});
it('FE-STUDIO-RAIL-005: opens the section the shell holds, and tells the shell which one was picked', () => {
const onSection = vi.fn();
const view = draw({ section: 'theme', onSection });
expect(panelTitle()).toBe(TITLES.theme);
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.layers' }));
expect(onSection).toHaveBeenCalledWith('layers');
view.unmount();
draw({ section: 'layers', onSection });
expect(panelTitle()).toBe(TITLES.layers);
});
it('FE-STUDIO-RAIL-006: finds the stickers under Elements, beside the icons', () => {
draw();
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.elements' }));
const kinds = screen.getAllByRole('tab').filter((t) => t.classList.contains('st-subtab'));
expect(kinds.map((t) => t.textContent)).toEqual([
'journey.studio.text',
'journey.studio.shapes',
'journey.studio.lines',
'journey.studio.frames',
'journey.studio.icons',
'journey.studio.stickers',
]);
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.stickers' }));
expect(screen.getByRole('textbox', { name: 'journey.studio.searchStickers' })).toBeTruthy();
});
});
@@ -0,0 +1,77 @@
import { Fragment, useRef, type KeyboardEvent } from 'react';
import '../../../styles/studio-panels.css';
import { rovingTarget } from '../studioRoving';
import { RAIL_IDS, RAIL_SECTIONS, type RailSection } from './railSections';
/**
* The rail down the side of Studio: one tab per section, each an icon over its
* name.
*
* A list of tabs to a screen reader and to the keyboard, as it is to the eye:
* Tab reaches the open section, the arrows move up and down the rail, Home and
* End jump to its ends, and Enter or Space opens a section (studioRoving.ts).
* A name longer than the rail is wide wraps onto a second line and breaks
* inside a word only when the word alone is wider than the rail; the title
* carries it whole either way.
*/
export function StudioRail({
section,
onSection,
t,
}: {
section: RailSection;
onSection: (id: RailSection) => void;
t: (k: string) => string;
}) {
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
const next = rovingTarget(e.key, index, RAIL_SECTIONS.length, { vertical: true, rtl: false });
if (next === null) return;
e.preventDefault();
tabs.current[next]?.focus();
};
return (
<div className="st-rail">
<div
className="st-rail-tabs"
role="tablist"
aria-orientation="vertical"
aria-label={t('journey.studio.sections')}
>
{RAIL_SECTIONS.map((entry, i) => {
const on = entry.id === section;
const label = t(entry.labelKey);
const Icon = entry.icon;
return (
<Fragment key={entry.id}>
{entry.startsGroup && <span className="st-rail-sep" aria-hidden />}
<button
type="button"
role="tab"
id={RAIL_IDS.tab(entry.id)}
aria-selected={on}
aria-controls={RAIL_IDS.panel}
tabIndex={on ? 0 : -1}
className={`st-rail-btn ${on ? 'is-on' : ''}`}
ref={(el) => {
tabs.current[i] = el;
}}
onClick={() => onSection(entry.id)}
onKeyDown={(e) => onKeyDown(e, i)}
title={label}
aria-label={label}
>
<Icon size={18} strokeWidth={1.7} aria-hidden />
<span className="st-rail-label" aria-hidden>
{label}
</span>
</button>
</Fragment>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,108 @@
import { bookPageSetupSchema, normalizeBookDocument } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { centred, markDrop, photoDrop, prosConsDrop, textDrop, type DropPlace } from './contentDrops';
/**
* FE-STUDIO-DROPS-001 to FE-STUDIO-DROPS-005: what the Content panel drops on
* a page, centred on the open page, sized for what it is, and readable by the
* document contract afterwards.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
const spread: DropPlace = { page, single: false };
const cover: DropPlace = { page, single: true };
describe('the drops of the Content panel', () => {
it('FE-STUDIO-DROPS-001: centres a frame across a spread, and on the one page of a cover', () => {
expect(centred(spread, 100, 50)).toEqual({ x: 160, y: 80, w: 100, h: 50 });
expect(centred(cover, 100, 50)).toEqual({ x: 55, y: 80, w: 100, h: 50 });
});
it('FE-STUDIO-DROPS-002: drops a photo in a 4:3 frame a little over half the page', () => {
const el = photoDrop(spread, 12);
expect(el).toMatchObject({ kind: 'photo', photoId: 12, fit: 'cover', focalX: 0.5, focalY: 0.5 });
expect(el.frame.w).toBeCloseTo(115.5, 5);
expect(el.frame.h).toBeCloseTo(115.5 * 0.75, 5);
expect(el.frame.x + el.frame.w / 2).toBeCloseTo(210, 5);
});
it('FE-STUDIO-DROPS-003: keeps a text bound to its entry, with the fact behind formatted words', () => {
const title = textDrop(spread, 'Bergen', { size: 22, weight: 700 }, { entryId: 7, source: 'entry.title' });
expect(title).toMatchObject({
kind: 'text',
text: 'Bergen',
size: 22,
weight: 700,
leading: 1.1,
tracking: -0.02,
binding: { source: 'entry.title', entryId: 7 },
overridden: false,
});
expect(title.frame.w).toBe(178);
const point = textDrop(
spread,
'60° N',
{ size: 8, weight: 700 },
{ entryId: 7, source: 'entry.location', format: 'dms', value: '60.39,5.32' },
75.6
);
expect(point).toMatchObject({
binding: { source: 'entry.location', entryId: 7, format: 'dms', value: '60.39,5.32' },
});
expect(point.frame.w).toBe(75.6);
const story = textDrop(spread, 'A long day', { size: 10, weight: 400 }, { entryId: 7, source: 'entry.story' });
expect(story).toMatchObject({ leading: 1.55, tracking: 0, binding: { source: 'entry.story', entryId: 7 } });
expect(story.kind === 'text' && story.binding).not.toHaveProperty('format');
});
it('FE-STUDIO-DROPS-004: drops a mood mark with its code and words, and both columns of pros and cons', () => {
expect(markDrop(spread, 'mood', 'good', 'Good')).toMatchObject({
kind: 'badge',
variant: 'mood',
code: 'good',
text: 'Good',
showIcon: true,
showLabel: true,
});
const list = prosConsDrop(spread, { pros: ['Warm'], cons: ['Late', 'Loud'] }, { pro: 'Pros', con: 'Cons' });
expect(list).toMatchObject({
kind: 'list',
layout: 'columns',
proLabel: 'Pros',
conLabel: 'Cons',
items: [
{ text: 'Warm', tone: 'pro' },
{ text: 'Late', tone: 'con' },
{ text: 'Loud', tone: 'con' },
],
});
// Tall enough for the longer column, never past most of the page.
expect(list.frame.h).toBeCloseTo(6 * (0.86 + 1.7 * 2), 5);
const long = prosConsDrop(
spread,
{ pros: Array.from({ length: 40 }, (_, i) => `p${i}`), cons: [] },
{
pro: 'Pros',
con: 'Cons',
}
);
expect(long.frame.h).toBeCloseTo(210 * 0.6, 5);
});
it('FE-STUDIO-DROPS-005: drops what the document contract reads back whole', () => {
const elements = [
photoDrop(spread, 3),
textDrop(spread, 'Bergen', { size: 22, weight: 700 }, { entryId: 7, source: 'entry.title' }),
markDrop(spread, 'weather', 'sunny', 'Sunny'),
prosConsDrop(spread, { pros: ['Warm'], cons: [] }, { pro: 'Pros', con: 'Cons' }),
];
const doc = normalizeBookDocument({
version: 1,
title: 'T',
page,
spreads: [{ id: 'sp1', role: 'inner', entryId: null, background: null, elements, parked: [] }],
});
expect(doc.spreads[0]?.elements.map((e) => e.kind)).toEqual(['photo', 'text', 'badge', 'list']);
});
});
@@ -0,0 +1,152 @@
import type { BookCoordFormat, BookElement, BookFrame, BookPageSetup } from '@trek/shared';
import { bookElementId as uid } from '@trek/shared';
/**
* What the Content panel drops on a page: a photo, a piece of an entry still
* attached to it, a mood or weather mark, an entry's pros and cons.
*
* Each arrives centred on the page that is open, across both pages of a
* spread or on the one page of a cover, at a size that suits it. Pure, so the
* panel's buttons and the tests build the same elements.
*/
/** Where a drop lands: the book's page, and whether the open page is a single one (a cover). */
export interface DropPlace {
page: BookPageSetup;
single: boolean;
}
/** The parts of an entry a text drop is bound to. */
type EntryTextSource = 'entry.title' | 'entry.story' | 'entry.location' | 'entry.date';
/** A frame of this size, centred on the open page or spread. */
export function centred({ page, single }: DropPlace, w: number, h: number): BookFrame {
const width = single ? page.pageWidth : page.pageWidth * 2;
return { x: (width - w) / 2, y: (page.pageHeight - h) / 2, w, h };
}
/** A photo, a little over half the short side of the page, in a 4:3 frame. */
export function photoDrop(place: DropPlace, photoId: number): BookElement {
const side = Math.min(place.page.pageWidth, place.page.pageHeight) * 0.55;
return {
id: uid('p'),
kind: 'photo',
frame: centred(place, side, side * 0.75),
rotation: 0,
opacity: 1,
locked: false,
photoId,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
} as BookElement;
}
/**
* One piece of the entry, dropped on the page and still attached to it.
*
* `format` is what separates a place name from a set of coordinates: both read
* the entry's location, and which of the two gets printed is a property of the
* binding rather than a second source. `width` exists because a line of
* coordinates arriving inside a full-column selection rectangle looks like a
* bug; only the short marks pass it. `value` is the fact behind formatted
* words (the ISO date, the point), so a book can re-read its own text.
*/
export function textDrop(
place: DropPlace,
text: string,
style: { size: number; weight: 400 | 700 },
binding: { entryId: number; source: EntryTextSource; format?: BookCoordFormat; value?: string },
width?: number
): BookElement {
const { size, weight } = style;
const w = width ?? place.page.pageWidth - 32;
return {
id: uid('t'),
kind: 'text',
frame: centred(place, w, size * 0.4 + 10),
rotation: 0,
opacity: 1,
locked: false,
text,
font: 'sans',
size,
weight,
italic: false,
align: 'left',
leading: weight === 700 ? 1.1 : 1.55,
tracking: weight === 700 ? -0.02 : 0,
color: '#1a1a1a',
binding: {
source: binding.source,
entryId: binding.entryId,
...(binding.format ? { format: binding.format } : {}),
...(binding.value ? { value: binding.value } : {}),
},
overridden: false,
} as BookElement;
}
/** What every mark and list starts from: upright, opaque, in the book's ink with one warm accent. */
const MARK_BASE = {
rotation: 0,
opacity: 1,
locked: false,
font: 'sans' as const,
color: '#1a1a1a',
accent: '#c2410c',
textScale: 1,
stale: false,
};
/**
* A mood or a weather mark, drawn with the icon the journey page uses.
*
* `code` holds the key rather than the label, so the element carries what the
* entry recorded and the renderer decides how to draw it.
*/
export function markDrop(place: DropPlace, variant: 'mood' | 'weather', code: string, label: string): BookElement {
return {
...MARK_BASE,
id: uid('bd'),
kind: 'badge',
autoColor: true,
// Set here as well as in the contract: this object is cast, not parsed.
showIcon: true,
showLabel: true,
autoIconColor: true,
iconColor: '#111111',
frame: centred(place, place.page.pageWidth * 0.22, place.page.pageHeight * 0.062),
variant,
text: label,
sub: '',
code,
style: 'plain',
} as BookElement;
}
/** Both columns of an entry's pros and cons, as one element, tall enough for its longer column. */
export function prosConsDrop(
place: DropPlace,
items: { pros: string[]; cons: string[] },
labels: { pro: string; con: string }
): BookElement {
const rows = Math.max(items.pros.length, items.cons.length, 1);
return {
...MARK_BASE,
id: uid('li'),
kind: 'list',
// What ListView needs for `rows` lines at its largest line size, so the element arrives filled.
frame: centred(place, place.page.pageWidth * 0.72, Math.min(place.page.pageHeight * 0.6, 6 * (0.86 + 1.7 * rows))),
items: [
...items.pros.map((text) => ({ text, tone: 'pro' as const })),
...items.cons.map((text) => ({ text, tone: 'con' as const })),
],
layout: 'columns',
showMarks: true,
proLabel: labels.pro,
conLabel: labels.con,
} as BookElement;
}
@@ -0,0 +1,42 @@
import { Compass, Files, ImageIcon, Layers, LayoutTemplate, Palette, Shapes, type LucideIcon } from 'lucide-react';
/**
* The sections of the rail, in the order a book is made.
*
* The first five put things on a page, read from top to bottom the way the
* work goes: which page, the journey's own photos and words, an arrangement
* for them, things to add (type, shapes, lines, frames, icons, stickers), and
* the journey's figures. The last two are about the book and the page as a
* whole, its look and what lies in front of what, and the rail sets them
* apart with a rule.
*
* Stickers used to be a section of their own beside Elements. They are a kind
* of thing to add like a shape or an icon, so they are now a tab of Elements,
* and the rail holds one place to add things instead of two.
*/
export type RailSection = 'pages' | 'content' | 'templates' | 'elements' | 'travel' | 'theme' | 'layers';
interface RailEntry {
id: RailSection;
icon: LucideIcon;
labelKey: string;
/** The first section of the second group, which the rail sets apart. */
startsGroup?: boolean;
}
export const RAIL_SECTIONS: readonly RailEntry[] = [
{ id: 'pages', icon: Files, labelKey: 'journey.studio.pages' },
{ id: 'content', icon: ImageIcon, labelKey: 'journey.studio.content' },
{ id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' },
{ id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' },
{ id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' },
{ id: 'theme', icon: Palette, labelKey: 'studio.theme.panel', startsGroup: true },
{ id: 'layers', icon: Layers, labelKey: 'journey.studio.layers' },
];
/** The ids the rail's tabs and the panel they open share. */
export const RAIL_IDS = {
tab: (id: RailSection) => `st-rail-tab-${id}`,
panel: 'st-rail-panel',
} as const;
@@ -0,0 +1,94 @@
import { useRef, useState, type DragEvent } from 'react';
import { useToast } from '../../shared/Toast';
import type { PhotoFilter } from '../photoFilter';
import type { StudioUploader } from '../studioUpload';
/**
* The way pictures get into the Content panel: a file picked, a file dropped
* anywhere on the panel, or a photo provider's picker (#2271).
*
* Into the entry the browser is filtered to, otherwise into the gallery. The
* filter is the one thing on screen that names an entry, so it is also the one
* thing that can say where a picture belongs. Afterwards the new pictures get
* a filter of their own, because the gallery sorts them to its end, and twelve
* pictures that arrive out of sight read as twelve that did not arrive.
*/
export function useContentUpload({
filter,
showArrived,
onUpload,
onBrowseProvider,
enabled,
t,
}: {
filter: PhotoFilter;
/** Shows the pictures that just landed, and lets go of the search that might hide them. */
showArrived: (photoIds: number[]) => void;
onUpload: StudioUploader;
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>;
/** False for a viewer, or on a tab that shows no photos: nothing is taken. */
enabled: boolean;
t: (k: string, params?: Record<string, string | number>) => string;
}) {
const toast = useToast();
const fileInput = useRef<HTMLInputElement>(null);
const [upload, setUpload] = useState<{ done: number; total: number } | null>(null);
// A ref as well as the state: two drops in one second would both read the state before either had set it.
const sending = useRef(false);
const [dropOver, setDropOver] = useState(false);
const entryId = filter.kind === 'entry' ? filter.id : null;
const send = async (files: File[]) => {
if (sending.current || !files.length) return;
sending.current = true;
setUpload({ done: 0, total: files.length });
try {
const sent = await onUpload(files, entryId, (p) => setUpload({ done: p.done, total: p.total }));
if (sent.photoIds.length) {
showArrived(sent.photoIds);
toast.success(t('journey.photosUploaded', { count: sent.photoIds.length }));
}
} finally {
sending.current = false;
setUpload(null);
}
};
/** From a photo provider: into the same place an upload goes, and shown the same way. */
const pickFromProvider = async (provider: string) => {
if (!onBrowseProvider) return;
const photoIds = await onBrowseProvider(provider, entryId);
if (photoIds.length) showArrived(photoIds);
};
/*
* The whole scrolling area takes a drop, not just the dashed cell: a drag
* from the desktop arrives with the pointer wherever it arrives. Only a file
* drag is claimed; the panel's own photo drags carry another type and go to
* the sheet.
*/
const dropZone = enabled
? {
onDragOver: (e: DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setDropOver(true);
},
onDragLeave: (e: DragEvent<HTMLDivElement>) => {
// Moving onto a child of the box is not leaving the box.
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropOver(false);
},
onDrop: (e: DragEvent<HTMLDivElement>) => {
const files = Array.from(e.dataTransfer.files ?? []);
if (!files.length) return;
e.preventDefault();
setDropOver(false);
void send(files);
},
}
: {};
return { fileInput, upload, dropOver, dropZone, send, pickFromProvider };
}
@@ -120,8 +120,9 @@ export function pickFaceRules(rules: readonly FaceRule[], uses: FaceUses): FaceR
const slanted = ofFamily.filter((r) => r.italic === (italicText === 'true')); const slanted = ofFamily.filter((r) => r.italic === (italicText === 'true'));
const styled = slanted.length ? slanted : ofFamily.filter((r) => !r.italic); const styled = slanted.length ? slanted : ofFamily.filter((r) => !r.italic);
if (!styled.length) continue; if (!styled.length) continue;
const nearest = styled.reduce((best, r) => const nearest = styled.reduce(
Math.abs(r.weight - weight) < Math.abs(best.weight - weight) ? r : best (best, r) => (Math.abs(r.weight - weight) < Math.abs(best.weight - weight) ? r : best),
styled[0]
).weight; ).weight;
for (const rule of styled) { for (const rule of styled) {
if (rule.weight === nearest && coversAny(rule.unicodeRange, chars)) picked.set(rule.url, rule); if (rule.weight === nearest && coversAny(rule.unicodeRange, chars)) picked.set(rule.url, rule);
@@ -2,7 +2,7 @@ import type { BookElement, BookSpread } from '@trek/shared';
import { bookShapeElementSchema, bookTextElementSchema } from '@trek/shared'; import { bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { dropOn, layerLabel, layerRows, rowIds } from './studioLayerRows'; import { dropOn, layerKeyAction, layerLabel, layerRows, rowIds } from './studioLayerRows';
import { shortcutKeys } from './studioShortcut'; import { shortcutKeys } from './studioShortcut';
/** The value, or a failed test when it is missing. */ /** The value, or a failed test when it is missing. */
@@ -59,6 +59,42 @@ describe('dropOn', () => {
}); });
}); });
describe('layerKeyAction', () => {
const open = layerRows(spread, new Set());
const ltr = { collapsed: new Set<string>(), rtl: false, additive: false };
it('walks the rows with Up, Down, Home and End, and stops at the ends', () => {
expect(layerKeyAction('ArrowDown', 0, open, ltr)).toEqual({ type: 'focus', index: 1 });
expect(layerKeyAction('ArrowDown', 4, open, ltr)).toEqual({ type: 'focus', index: 4 });
expect(layerKeyAction('ArrowUp', 0, open, ltr)).toEqual({ type: 'focus', index: 0 });
expect(layerKeyAction('End', 1, open, ltr)).toEqual({ type: 'focus', index: 4 });
expect(layerKeyAction('Home', 3, open, ltr)).toEqual({ type: 'focus', index: 0 });
});
it('opens and closes a group, and steps between it and its members, mirrored right to left', () => {
const folded = layerRows(spread, new Set(['g']));
const closed = { ...ltr, collapsed: new Set(['g']) };
expect(layerKeyAction('ArrowLeft', 1, open, ltr)).toEqual({ type: 'collapse', groupId: 'g' });
expect(layerKeyAction('ArrowRight', 1, folded, closed)).toEqual({ type: 'expand', groupId: 'g' });
expect(layerKeyAction('ArrowRight', 1, open, ltr)).toEqual({ type: 'focus', index: 2 });
expect(layerKeyAction('ArrowLeft', 3, open, ltr)).toEqual({ type: 'focus', index: 1 });
expect(layerKeyAction('ArrowLeft', 1, folded, closed)).toBeNull();
expect(layerKeyAction('ArrowRight', 0, open, ltr)).toBeNull();
expect(layerKeyAction('ArrowLeft', 0, open, ltr)).toBeNull();
const rtl = { ...ltr, rtl: true };
expect(layerKeyAction('ArrowRight', 1, open, rtl)).toEqual({ type: 'collapse', groupId: 'g' });
expect(layerKeyAction('ArrowLeft', 1, open, rtl)).toEqual({ type: 'focus', index: 2 });
});
it('selects with Enter or Space, renames with F2, and leaves every other key to the page', () => {
expect(layerKeyAction('Enter', 2, open, ltr)).toEqual({ type: 'choose', additive: false });
expect(layerKeyAction(' ', 2, open, { ...ltr, additive: true })).toEqual({ type: 'choose', additive: true });
expect(layerKeyAction('F2', 0, open, ltr)).toEqual({ type: 'rename' });
expect(layerKeyAction('Delete', 0, open, ltr)).toBeNull();
expect(layerKeyAction('Enter', 9, open, ltr)).toBeNull();
});
});
describe('layerLabel', () => { describe('layerLabel', () => {
it('uses the name, then a text’s first words, then the kind', () => { it('uses the name, then a text’s first words, then the kind', () => {
expect(layerLabel(shape('a', { name: 'Sun' }), 'Shape')).toBe('Sun'); expect(layerLabel(shape('a', { name: 'Sun' }), 'Shape')).toBe('Sun');
@@ -91,3 +91,61 @@ export function selectionTitle(spread: BookSpread | undefined, selection: string
} }
return t('journey.studio.multiple'); return t('journey.studio.multiple');
} }
/** What a key pressed on a row of the layers tree does. */
export type LayerKeyAction =
| { type: 'focus'; index: number }
| { type: 'expand'; groupId: string }
| { type: 'collapse'; groupId: string }
| { type: 'choose'; additive: boolean }
| { type: 'rename' };
/** Up and Down along the rows, Home and End to the ends; the tree stops at either end. */
function alongTree(key: string, index: number, count: number): number | null {
if (key === 'Home') return 0;
if (key === 'End') return count - 1;
if (key === 'ArrowDown') return Math.min(index + 1, count - 1);
if (key === 'ArrowUp') return Math.max(index - 1, 0);
return null;
}
/** Into a group: a closed one opens, an open one hands the focus to its first member. */
function inward(row: LayerRow, index: number, collapsed: ReadonlySet<string>): LayerKeyAction | null {
if (row.type !== 'group') return null;
return collapsed.has(row.groupId) ? { type: 'expand', groupId: row.groupId } : { type: 'focus', index: index + 1 };
}
/** Out of a group: an open one closes, a member hands the focus to its group's row. */
function outward(rows: readonly LayerRow[], index: number, collapsed: ReadonlySet<string>): LayerKeyAction | null {
const row = rows[index];
if (row?.type === 'group') return collapsed.has(row.groupId) ? null : { type: 'collapse', groupId: row.groupId };
if (!row?.inGroup) return null;
// A member's group is the nearest group row above it: members are listed right under their group.
for (let i = index - 1; i >= 0; i--) if (rows[i]?.type === 'group') return { type: 'focus', index: i };
return null;
}
/**
* The tree's keyboard, as a tree view has it: Up and Down move along the
* rows, Home and End to the ends; Right opens a closed group and steps into
* an open one, Left closes an open group and steps from a member up to its
* group, the two mirrored in a language read right to left; Enter or Space
* selects the row as a click does, `additive` (Shift, Ctrl or Cmd held)
* adding it; F2 renames it. Null for a key the tree leaves to the page.
*/
export function layerKeyAction(
key: string,
index: number,
rows: readonly LayerRow[],
opts: { collapsed: ReadonlySet<string>; rtl: boolean; additive: boolean }
): LayerKeyAction | null {
const row = rows[index];
if (!row) return null;
if (key === 'Enter' || key === ' ') return { type: 'choose', additive: opts.additive };
if (key === 'F2') return { type: 'rename' };
const along = alongTree(key, index, rows.length);
if (along !== null) return { type: 'focus', index: along };
if (key === (opts.rtl ? 'ArrowLeft' : 'ArrowRight')) return inward(row, index, opts.collapsed);
if (key === (opts.rtl ? 'ArrowRight' : 'ArrowLeft')) return outward(rows, index, opts.collapsed);
return null;
}
@@ -0,0 +1,32 @@
import type { BookLineElement, BookPhotoElement, BookShapeElement } from '@trek/shared';
/**
* The fixed sets of looks Studio offers in more than one place, with the words
* for each: the six ways a picture is framed, offered when an empty frame is
* added and when a photo is restyled, and the three strokes a shape's outline
* and a drawn line share.
*
* Written once here so the Elements panel and the inspector cannot drift into
* two lists, and so every key is named in full where the checker of
* translation keys can read it.
*/
export type FrameStyle = NonNullable<BookPhotoElement['frameStyle']>;
export const FRAME_STYLE_OPTIONS: readonly { value: FrameStyle; labelKey: string }[] = [
{ value: 'none', labelKey: 'journey.studio.plainFrame' },
{ value: 'polaroid', labelKey: 'journey.studio.polaroidFrame' },
{ value: 'white', labelKey: 'journey.studio.whiteFrame' },
{ value: 'shadow', labelKey: 'journey.studio.shadowFrame' },
{ value: 'film', labelKey: 'journey.studio.filmFrame' },
{ value: 'tape', labelKey: 'journey.studio.tapeFrame' },
];
/** A shape's outline and a drawn line take the same three strokes. */
type StrokeStyle = NonNullable<BookShapeElement['strokeStyle']> & NonNullable<BookLineElement['dash']>;
export const STROKE_STYLE_OPTIONS: readonly { value: StrokeStyle; labelKey: string }[] = [
{ value: 'solid', labelKey: 'journey.studio.strokeSolid' },
{ value: 'dashed', labelKey: 'journey.studio.strokeDashed' },
{ value: 'dotted', labelKey: 'journey.studio.strokeDotted' },
];
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it } from 'vitest';
import { rovingTarget, runsRtl } from './studioRoving';
/**
* FE-STUDIO-ROVING-001 to FE-STUDIO-ROVING-004: the arrow keys through a strip
* of tabs, the rail and the tab rows inside the panels.
*/
const ACROSS = { vertical: false, rtl: false };
const DOWN = { vertical: true, rtl: false };
afterEach(() => {
document.documentElement.removeAttribute('dir');
document.body.innerHTML = '';
});
describe('rovingTarget', () => {
it('FE-STUDIO-ROVING-001: moves along a row with the arrows, wrapping at both ends', () => {
expect(rovingTarget('ArrowRight', 0, 3, ACROSS)).toBe(1);
expect(rovingTarget('ArrowRight', 2, 3, ACROSS)).toBe(0);
expect(rovingTarget('ArrowLeft', 0, 3, ACROSS)).toBe(2);
expect(rovingTarget('ArrowLeft', 2, 3, ACROSS)).toBe(1);
// Up and down mean nothing to a row.
expect(rovingTarget('ArrowDown', 0, 3, ACROSS)).toBeNull();
});
it('FE-STUDIO-ROVING-002: follows the reading direction across, and goes up and down a column', () => {
const rtl = { vertical: false, rtl: true };
expect(rovingTarget('ArrowLeft', 0, 3, rtl)).toBe(1);
expect(rovingTarget('ArrowRight', 0, 3, rtl)).toBe(2);
expect(rovingTarget('ArrowDown', 1, 4, DOWN)).toBe(2);
expect(rovingTarget('ArrowUp', 0, 4, DOWN)).toBe(3);
expect(rovingTarget('ArrowRight', 1, 4, DOWN)).toBeNull();
});
it('FE-STUDIO-ROVING-003: jumps to the ends, and takes no key on an empty strip', () => {
expect(rovingTarget('Home', 2, 5, DOWN)).toBe(0);
expect(rovingTarget('End', 0, 5, ACROSS)).toBe(4);
expect(rovingTarget('Enter', 1, 5, ACROSS)).toBeNull();
expect(rovingTarget('ArrowDown', 0, 0, DOWN)).toBeNull();
});
});
describe('runsRtl', () => {
it('FE-STUDIO-ROVING-004: reads the direction of the page around the element', () => {
const tab = document.createElement('button');
document.body.append(tab);
expect(runsRtl(tab)).toBe(false);
document.documentElement.setAttribute('dir', 'rtl');
expect(runsRtl(tab)).toBe(true);
const ltr = document.createElement('div');
ltr.setAttribute('dir', 'ltr');
ltr.append(tab);
document.body.append(ltr);
expect(runsRtl(tab)).toBe(false);
});
});
@@ -0,0 +1,37 @@
/**
* Arrow keys through a strip of tabs: the rail down the side of Studio and the
* tab rows inside its panels.
*
* One tab of a strip is in the Tab order, the chosen one, and the arrows move
* the focus along the strip from there, wrapping at both ends; Home and End
* jump to the ends. A strip read across follows the reading direction, so in
* Arabic the left arrow moves on to the next tab. The focus moves, the choice
* does not: a panel opens on Enter or Space, as a button does, because the
* panels behind these tabs are photo grids and libraries, and arrowing past one
* should not draw it.
*/
interface RovingStrip {
/** Read top to bottom (the rail) rather than across (a tab row). */
vertical: boolean;
/** The strip runs right to left. Only read for a strip that runs across. */
rtl: boolean;
}
/** The tab a key moves the focus to, or null for a key the strip leaves alone. */
export function rovingTarget(key: string, index: number, count: number, strip: RovingStrip): number | null {
if (count <= 0) return null;
const last = count - 1;
if (key === 'Home') return 0;
if (key === 'End') return last;
const back = strip.vertical ? 'ArrowUp' : strip.rtl ? 'ArrowRight' : 'ArrowLeft';
const ahead = strip.vertical ? 'ArrowDown' : strip.rtl ? 'ArrowLeft' : 'ArrowRight';
if (key === back) return index <= 0 ? last : index - 1;
if (key === ahead) return index >= last ? 0 : index + 1;
return null;
}
/** Whether the element sits in right to left text, read from the page rather than from the locale. */
export function runsRtl(el: Element): boolean {
return el.closest('[dir]')?.getAttribute('dir') === 'rtl';
}
@@ -0,0 +1,33 @@
import type { BookTravelContext, JourneyStats } from '@trek/shared';
/**
* What the book engine's travel factories read, as the Studio knows it: the
* journey's figures, its recorded way and the words in the reader's
* language. The Travel panel builds its tiles with it, and the Templates
* panel lays a template's map with it, so both draw the same journey.
*
* The day word is the engine's template: `t` leaves the `{day}` in it
* untouched, and `bookDayText` puts the number there.
*/
export function studioTravelContext(
stats: JourneyStats,
path: [number, number][][],
locale: string,
t: (k: string) => string
): BookTravelContext {
return {
locale,
stats,
path,
entries: [],
labels: {
day: t('journey.studio.day'),
stations: t('journey.studio.stations'),
summary: t('journey.studio.summary'),
countries: t('journey.studio.countries'),
distance: t('journey.studio.metric.distance'),
pros: t('journey.editor.pros'),
cons: t('journey.editor.cons'),
},
};
}
@@ -0,0 +1,179 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BookDocument } from '@trek/shared';
import { bookPageSetupSchema, decoratedTemplatesFor, normalizeBookDocument } from '@trek/shared';
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
import { useStudioStore } from '../../../store/studioStore';
import { StudioTemplatesPanel } from './StudioTemplatesPanel';
/**
* The Templates panel: the decorated templates of the open page's kind, each
* a button named after its template and drawn as the page it lays, a style
* filter, and a click that lays the page out in one undo step.
*/
const t = (k: string) => k;
const page = bookPageSetupSchema.parse({});
const source = {
title: 'Azores',
subtitle: 'Nine islands in the Atlantic',
entries: [{ id: 4, title: 'Sintra', story: 'Up through the fog.', location: 'Serra de Sintra', date: '2026-05-14' }],
};
const book = (title = 'Portugal'): BookDocument =>
normalizeBookDocument({
version: 1,
title,
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads: [
{
id: 'sp-cover',
role: 'cover',
background: null,
entryId: null,
parked: [],
elements: [{ id: 'ph', kind: 'photo', frame: { x: 0, y: 0, w: 50, h: 50 }, photoId: 7 }],
},
{ id: 'sp-in', role: 'inner', background: null, entryId: 4, parked: [], elements: [] },
],
});
/* jsdom lays nothing out; the cards measure their row to scale the page drawing into it. */
const size = Object.getOwnPropertyDescriptors(HTMLElement.prototype);
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 220 });
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 600 });
});
afterAll(() => {
for (const key of ['offsetWidth', 'offsetHeight'] as const) {
const was = size[key];
if (was) Object.defineProperty(HTMLElement.prototype, key, was);
}
});
beforeEach(() => {
useStudioStore.getState().load(book());
useStudioStore.setState({ readOnly: false });
});
const panel = (onOpenContent = vi.fn()) =>
render(
<StudioTemplatesPanel
page={page}
pxPerMm={96 / 25.4}
t={t}
locale="en"
source={source}
stats={null}
path={[]}
onOpenContent={onOpenContent}
/>
);
const spreadOf = (i: number) => useStudioStore.getState().doc?.spreads[i];
describe('the Templates panel', () => {
it('offers the cover templates on the cover, each a named button drawn with the page photo and the book title', () => {
panel();
for (const tpl of decoratedTemplatesFor('single')) {
expect(screen.getByRole('button', { name: tpl.labelKey })).toBeTruthy();
}
expect(screen.queryByRole('button', { name: 'studio.templates.name.polaroidStack' })).toBeNull();
const card = screen.getByRole('button', { name: 'studio.templates.name.minimalSerifClassic' });
expect(card.textContent).toContain('Portugal');
expect(card.querySelector('img')?.getAttribute('src')).toContain('/api/photos/7/');
expect(screen.getByText('studio.templates.decorated')).toBeTruthy();
expect(screen.getByText('journey.studio.builtInLayouts')).toBeTruthy();
});
it('narrows the set to one archetype from the style chips', () => {
panel();
const chips = screen.getByRole('radiogroup', { name: 'studio.templates.style' });
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(8);
fireEvent.click(screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }));
expect(
screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }).getAttribute('aria-checked')
).toBe('true');
const names = decoratedTemplatesFor('single', 'postcard').map((tpl) => tpl.labelKey);
for (const name of names) expect(screen.getByRole('button', { name })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'studio.templates.name.gridBadgeCircle' })).toBeNull();
});
it('lays the page out on a click, as one undo step, keeping its photo', () => {
panel();
fireEvent.click(screen.getByRole('button', { name: 'studio.templates.name.postcardHouses' }));
const cover = spreadOf(0);
expect(cover?.elements.some((e) => e.kind === 'sticker' && e.stickerId === 'rowHouses')).toBe(true);
expect(cover?.elements.find((e) => e.kind === 'photo')).toMatchObject({ photoId: 7 });
expect(cover?.elements.some((e) => e.kind === 'text' && e.text === 'Portugal')).toBe(true);
expect(useStudioStore.getState().past).toHaveLength(1);
useStudioStore.getState().undo();
expect(spreadOf(0)?.elements.map((e) => e.id)).toEqual(['ph']);
});
it("sets a cover in the journey's subtitle, bound to it, and in the journey's title when the book has none", () => {
panel();
fireEvent.click(screen.getByRole('button', { name: 'studio.templates.name.minimalSerifClassic' }));
const texts = (spreadOf(0)?.elements ?? []).flatMap((e) => (e.kind === 'text' ? [e] : []));
expect(texts.map((e) => e.text)).toEqual(['Portugal', 'Nine islands in the Atlantic', '2026']);
expect(texts[1]?.binding).toEqual({ source: 'journey.subtitle' });
act(() => useStudioStore.getState().load(book('')));
fireEvent.click(screen.getByRole('button', { name: 'studio.templates.name.minimalSerifClassic' }));
const untitled = (spreadOf(0)?.elements ?? []).flatMap((e) => (e.kind === 'text' ? [e.text] : []));
expect(untitled[0]).toBe('Azores');
});
it('offers the spread templates on a double page, sets it in its entry and opens the photos for empty frames', () => {
useStudioStore.getState().setActiveSpread(1);
const onOpenContent = vi.fn();
panel(onOpenContent);
for (const tpl of decoratedTemplatesFor('inner')) {
expect(screen.getByRole('button', { name: tpl.labelKey })).toBeTruthy();
}
fireEvent.click(screen.getByRole('button', { name: 'studio.templates.name.storyQuote' }));
const inner = spreadOf(1);
const title = inner?.elements.find((e) => e.kind === 'text' && e.text === 'Sintra');
expect(title?.kind === 'text' && title.binding).toEqual({ source: 'entry.title', entryId: 4 });
expect(onOpenContent).toHaveBeenCalledTimes(1);
});
it('lays a built-in layout out from the same panel', () => {
useStudioStore.getState().setActiveSpread(1);
panel();
fireEvent.click(screen.getByText('journey.studio.tpl.grid4'));
expect(spreadOf(1)?.elements.filter((e) => e.kind === 'photo')).toHaveLength(4);
});
it('leaves what is locked where it is, as the assistant does, and lays out the rest', () => {
const locked = normalizeBookDocument({
...book(),
spreads: [
book().spreads[0],
{
id: 'sp-in',
role: 'inner',
background: null,
entryId: 4,
parked: [],
elements: [
{ id: 'panel', kind: 'shape', frame: { x: 0, y: 150, w: 420, h: 60 }, locked: true },
{ id: 'free', kind: 'photo', frame: { x: 230, y: 20, w: 60, h: 60 }, photoId: 8 },
{ id: 'kept', kind: 'photo', frame: { x: 10, y: 20, w: 60, h: 60 }, photoId: 9, locked: true },
],
},
],
});
useStudioStore.getState().load(locked);
useStudioStore.getState().setActiveSpread(1);
panel();
fireEvent.click(screen.getByText('journey.studio.tpl.grid4'));
const after = spreadOf(1)?.elements ?? [];
expect(after[0]).toEqual(locked.spreads[1]?.elements[0]);
expect(after.find((e) => e.id === 'kept')).toEqual(locked.spreads[1]?.elements[2]);
const poured = after.filter((e) => e.kind === 'photo' && !e.locked);
expect(poured).toHaveLength(4);
expect(poured.map((e) => (e.kind === 'photo' ? e.photoId : null))).toContain(8);
});
});
@@ -0,0 +1,255 @@
import type { BookDecoratedTemplate, BookPageSetup, BookSpread, BookTemplate, JourneyStats } from '@trek/shared';
import {
applyBookTemplate as applyTemplate,
BOOK_COVER_TEMPLATES as COVER_TEMPLATES,
layoutAroundLocks,
BOOK_TEMPLATES as TEMPLATES,
} from '@trek/shared';
import { memo, useDeferredValue, useMemo, useState } from 'react';
import { useElementSize } from '../../../hooks/useElementSize';
import { useStudioStore } from '../../../store/studioStore';
import '../../../styles/studio-templates.css';
import { SpreadThumb } from '../SpreadThumb';
import { Choice } from '../StudioControls';
import { PanelHead } from '../StudioPanelHead';
import { StudioSavedLayouts } from '../StudioSavedLayouts';
import {
ARCHETYPE_FILTERS,
emptyFrames,
layTemplate,
pageKind,
previewIds,
studioTemplateWords,
templatesForSpread,
templateTravel,
type ArchetypeFilter,
type TemplateContext,
type TemplateSource,
} from './templatesModel';
/**
* The Templates section of the rail: the decorated templates first, then the
* book's own layouts and the built-in wireframes.
*
* A decorated template is a whole designed page, and its card is that page as
* the click will lay it: this page's photos and words poured into it, in the
* book's theme, drawn by the page renderer at a small scale. Covers are drawn
* two to a row so the set can be compared at a glance; a double spread takes
* the full width. A row of style chips narrows the set to one archetype.
*/
/** The smallest a preview is drawn, and the button's border and padding the row's width still includes. */
const MIN_PREVIEW_W = 96;
const THUMB_CHROME = (2 + 3) * 2;
/** The gap between two cover cards in a row, as studio-templates.css sets it. */
const PAIR_GAP = 8;
type Translate = (k: string, params?: Record<string, string | number>) => string;
/** How wide a card draws its page, from the measured row: the full row, or half of it for a cover. */
function previewWidths(row: number, single: boolean): { full: number; card: number } {
const full = Math.max(MIN_PREVIEW_W, row - THUMB_CHROME);
return { full, card: single ? Math.max(MIN_PREVIEW_W / 2, (row - PAIR_GAP) / 2 - THUMB_CHROME) : full };
}
interface DecoratedCardProps {
tpl: BookDecoratedTemplate;
spread: BookSpread;
page: BookPageSetup;
ctx: TemplateContext;
width: number;
pxPerMm: number;
label: string;
onPick: (tpl: BookDecoratedTemplate) => void;
}
/** One decorated template, drawn as the page it makes of the page that is open. */
const DecoratedCard = memo(function DecoratedCard({
tpl,
spread,
page,
ctx,
width,
pxPerMm,
label,
onPick,
}: DecoratedCardProps) {
const preview = useMemo(
() => layTemplate(spread, tpl, page, ctx, previewIds(`tpl-${tpl.id}`)),
[spread, tpl, page, ctx]
);
return (
<button type="button" className="st-thumb st-tpl-card" title={label} aria-label={label} onClick={() => onPick(tpl)}>
<span aria-hidden className="st-tpl-art">
<SpreadThumb
spread={preview}
page={page}
width={width}
pxPerMm={pxPerMm}
fold={tpl.kind === 'inner'}
className="st-thumb-sheet"
/>
</span>
<span className="st-thumb-label st-tpl-label">{label}</span>
</button>
);
});
/** The built-in layouts, drawn as the frames they lay out: the wireframes Studio has always had. */
function BuiltInLayouts({
spread,
page,
pxPerMm,
width,
t,
onPick,
}: {
spread: BookSpread;
page: BookPageSetup;
pxPerMm: number;
width: number;
t: Translate;
onPick: (tpl: BookTemplate) => void;
}) {
const single = spread.role !== 'inner';
// A cover card is one page wide, not two: scaled as a spread it would draw at half size.
const scale = width / (page.pageWidth * (single ? 1 : 2) * pxPerMm);
const templates = single ? COVER_TEMPLATES : TEMPLATES;
return (
<div className="st-thumbs">
{templates.map((tpl) => (
<button type="button" key={tpl.id} className="st-thumb" onClick={() => onPick(tpl)}>
{/* A page is laid out from its top left in either reading direction. */}
<div dir="ltr" className="st-thumb-sheet" style={{ width, height: page.pageHeight * pxPerMm * scale }}>
{tpl.build(page).map((slot, i) => (
<span
key={i}
className={`st-tpl-slot is-${slot.kind}`}
style={{
insetInlineStart: slot.frame.x * pxPerMm * scale,
top: slot.frame.y * pxPerMm * scale,
width: slot.frame.w * pxPerMm * scale,
height: Math.max(2, slot.frame.h * pxPerMm * scale),
}}
/>
))}
{!single && <span className="st-tpl-fold" style={{ insetInlineStart: page.pageWidth * pxPerMm * scale }} />}
</div>
<span className="st-thumb-label">{t(tpl.labelKey)}</span>
</button>
))}
</div>
);
}
export function StudioTemplatesPanel({
page,
pxPerMm,
t,
locale,
source,
stats,
path,
onOpenContent,
}: {
page: BookPageSetup;
pxPerMm: number;
t: Translate;
locale: string;
source: TemplateSource;
stats: JourneyStats | null;
path: [number, number][][];
/** Opens the panel of the journey's photos, for frames a layout left empty. */
onOpenContent: () => void;
}) {
const doc = useStudioStore((s) => s.doc);
const active = useStudioStore((s) => s.activeSpread);
const commit = useStudioStore((s) => s.commit);
const select = useStudioStore((s) => s.select);
const [filter, setFilter] = useState<ArchetypeFilter>('all');
const list = useElementSize<HTMLDivElement>();
// The cards redraw from the page as it is; deferred, so typing on the page never waits on them.
const spread = useDeferredValue(doc?.spreads[active]);
const theme = doc?.theme;
const title = doc?.title ?? '';
const ctx = useMemo<TemplateContext | null>(() => {
if (!spread) return null;
const words = studioTemplateWords(title, spread, source, locale);
return { theme, words, travel: templateTravel(stats, path, locale, t), locale };
}, [spread, theme, title, source, stats, path, locale, t]);
if (!doc || !spread || !ctx) return null;
const single = spread.role !== 'inner';
const templates = templatesForSpread(spread, filter);
const { full, card: cardWidth } = previewWidths(list.width, single);
/**
* One undo step: the page laid out, nothing selected, and the photos at hand
* when frames are left empty. It works on the page as it is now, not as the
* deferred cards last drew it: a click right after moving to another page
* lays nothing when the layout is for the other kind of page.
*/
const lay = (make: (sp: BookSpread) => BookSpread | null) => {
const done: { page: BookSpread | null } = { page: null };
commit((d) => {
const now = d.spreads[active];
const next = now ? make(now) : null;
done.page = next;
return next ? { ...d, spreads: d.spreads.map((sp, i) => (i === active ? next : sp)) } : d;
});
if (!done.page) return;
select([]);
if (emptyFrames(done.page) > 0) onOpenContent();
};
const pickDecorated = (tpl: BookDecoratedTemplate) =>
lay((sp) =>
pageKind(sp) === tpl.kind
? layTemplate(sp, tpl, page, { ...ctx, words: studioTemplateWords(title, sp, source, locale) })
: null
);
// A built-in layout arranges what is not locked, as the assistant's does; a decorated one keeps locks itself.
const pickBuiltIn = (tpl: BookTemplate) =>
lay((sp) =>
pageKind(sp) === (tpl.role ?? 'inner') ? layoutAroundLocks(sp, (free) => applyTemplate(free, tpl, page)) : null
);
return (
<>
<PanelHead label={t('journey.studio.templates')} count={templates.length} />
<div className="st-panel-scroll">
<div className="st-section st-tpl-section">
<div className="st-section-label">{t('studio.templates.decorated')}</div>
<Choice<ArchetypeFilter>
value={filter}
ariaLabel={t('studio.templates.style')}
onPick={setFilter}
options={ARCHETYPE_FILTERS.map((a) => ({
value: a,
label: a === 'all' ? t('studio.templates.all') : t(`studio.templates.archetype.${a}`),
}))}
/>
<div className={`st-tpl-grid ${single ? 'is-pages' : 'is-spreads'}`} ref={list.ref}>
{templates.map((tpl) => (
<DecoratedCard
key={tpl.id}
tpl={tpl}
spread={spread}
page={page}
ctx={ctx}
width={cardWidth}
pxPerMm={pxPerMm}
label={t(tpl.labelKey)}
onPick={pickDecorated}
/>
))}
</div>
<p className="st-hint">{t('studio.templates.hint')}</p>
</div>
<StudioSavedLayouts page={page} width={full} pxPerMm={pxPerMm} t={t} />
<BuiltInLayouts spread={spread} page={page} pxPerMm={pxPerMm} width={full} t={t} onPick={pickBuiltIn} />
</div>
</>
);
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import type { BookSpread } from '@trek/shared';
import { bookSpreadSchema, journeyStatsSchema } from '@trek/shared';
import { pageKind, studioTemplateWords, templateTravel, templatesForSpread } from './templatesModel';
/**
* What the Templates panel lays a page out with: which templates a page
* takes, the words their empty roles take (the same the assistant's layout
* gives), and the journey's figures for a template's map.
*/
const t = (k: string) => `[${k}]`;
const spread = (role: BookSpread['role'], entryId: number | null = null) =>
bookSpreadSchema.parse({ id: `sp-${role}`, role, entryId, elements: [] });
const source = {
title: 'Azores',
subtitle: 'Nine islands in the Atlantic',
entries: [{ id: 4, title: 'Sintra', story: 'Up through the fog.', location: 'Serra de Sintra', date: '2026-05-14' }],
};
describe('the templates a page takes', () => {
it('takes the covers on every single page and the spreads on a double one', () => {
for (const role of ['cover', 'first', 'last', 'back'] as const) expect(pageKind(spread(role))).toBe('single');
expect(pageKind(spread('inner'))).toBe('inner');
expect(templatesForSpread(spread('back'), 'all').every((tpl) => tpl.kind === 'single')).toBe(true);
expect(templatesForSpread(spread('inner'), 'postcard').every((tpl) => tpl.archetype === 'postcard')).toBe(true);
});
});
describe('the words of a template', () => {
it("sets a cover in the book's title, the journey's when the book has none, and the journey's subtitle bound to it", () => {
const own = studioTemplateWords(' Portugal ', spread('cover'), source, 'en');
expect(own.title).toEqual({ text: 'Portugal' });
expect(own.subtitle).toEqual({ text: 'Nine islands in the Atlantic', binding: { source: 'journey.subtitle' } });
expect(own.date).toEqual({ text: '2026' });
expect(studioTemplateWords('', spread('cover'), source, 'en').title).toEqual({ text: 'Azores' });
});
it('leaves a cover without a title when neither the book nor the journey has one, rather than a sample word', () => {
const words = studioTemplateWords('', spread('cover'), { ...source, title: '', subtitle: null }, 'en');
expect(words.title).toBeUndefined();
expect(words.subtitle).toBeUndefined();
});
it('sets a page made from an entry in that entry, bound to it', () => {
const words = studioTemplateWords('Portugal', spread('inner', 4), source, 'en');
expect(words.title).toEqual({ text: 'Sintra', binding: { source: 'entry.title', entryId: 4 } });
expect(words.date?.binding).toEqual({ source: 'entry.date', entryId: 4, value: '2026-05-14' });
expect(studioTemplateWords('Portugal', spread('inner'), source, 'en')).toEqual({});
});
});
describe('the journey of a template map', () => {
it('has none while the figures load, and the travel words of the reader once they are in', () => {
expect(templateTravel(null, [], 'en', t)).toBeNull();
const stats = journeyStatsSchema.parse({
journeyId: 1,
distance: 1200,
days: 3,
steps: 2,
photos: 0,
places: 0,
furthest: 900,
countries: [],
points: [],
start: '2026-05-12',
end: '2026-05-14',
});
const travel = templateTravel(stats, [[[38.7, -9.1]]], 'de', t);
expect(travel).toMatchObject({ locale: 'de', stats, path: [[[38.7, -9.1]]], entries: [] });
expect(travel?.labels).toEqual({
day: '[journey.studio.day]',
stations: '[journey.studio.stations]',
summary: '[journey.studio.summary]',
countries: '[journey.studio.countries]',
distance: '[journey.studio.metric.distance]',
pros: '[journey.editor.pros]',
cons: '[journey.editor.cons]',
});
});
});
@@ -0,0 +1,116 @@
import type {
BookCoverArchetype,
BookDecoratedTemplate,
BookDocument,
BookPageSetup,
BookSpread,
BookTemplateKind,
BookTemplateWords,
BookTravelContext,
JourneyStats,
} from '@trek/shared';
import { BOOK_COVER_ARCHETYPES, applyDecoratedTemplate, bookTemplateWords, decoratedTemplatesFor } from '@trek/shared';
import { studioTravelContext } from '../studioTravelContext';
/**
* What the Templates panel lays out and draws: the decorated templates a page
* can take, the words and the figures they are filled with, and the page one
* template makes. The card and the click run the same function, so a card
* shows the page the click gives, with this page's own photos and words in it.
*/
export type ArchetypeFilter = BookCoverArchetype | 'all';
/** The panel's style filter: every template, or those of one archetype. */
export const ARCHETYPE_FILTERS: readonly ArchetypeFilter[] = ['all', ...BOOK_COVER_ARCHETYPES];
/** Which templates a page takes: a single page (the cover, the first, the last, the back) or a double spread. */
export function pageKind(spread: BookSpread): BookTemplateKind {
return spread.role === 'inner' ? 'inner' : 'single';
}
/** The decorated templates for a page: covers for a single page, spreads for a double one. */
export function templatesForSpread(spread: BookSpread, filter: ArchetypeFilter): BookDecoratedTemplate[] {
return decoratedTemplatesFor(pageKind(spread), filter === 'all' ? null : filter);
}
/** What the Studio knows of the journey that a template takes words and a map from. */
export interface TemplateSource {
/** The journey's title, for a cover of a book without its own, and its subtitle. */
title?: string;
subtitle?: string | null;
entries: readonly {
id: number;
title: string | null;
story: string | null;
location: string | null;
date: string | null;
}[];
}
/** Everything a page is laid out on a template with. */
export interface TemplateContext {
theme: BookDocument['theme'];
words: BookTemplateWords;
travel: BookTravelContext | null;
locale: string;
}
/**
* The words a template's empty roles take, as the assistant's layout gives
* them (template-op.ts in shared): on a single page the book's title, or the
* journey's when the book has none, the journey's subtitle and the year; on
* a page made from a journal entry that entry's title, place, date and story.
*/
export function studioTemplateWords(
bookTitle: string,
spread: BookSpread,
source: TemplateSource,
locale: string
): BookTemplateWords {
return bookTemplateWords(spread, {
bookTitle: bookTitle.trim() || (source.title ?? ''),
subtitle: source.subtitle ?? null,
locale,
entries: source.entries,
});
}
/** The journey's figures and way, for a template's map; null while they are loading or failed. */
export function templateTravel(
stats: JourneyStats | null,
path: [number, number][][],
locale: string,
t: (k: string) => string
): BookTravelContext | null {
return stats ? studioTravelContext(stats, path, locale, t) : null;
}
/** A page laid out on a template: what a card draws and what a click commits. */
export function layTemplate(
spread: BookSpread,
tpl: BookDecoratedTemplate,
page: BookPageSetup,
ctx: TemplateContext,
newId?: (prefix: string) => string
): BookSpread {
return applyDecoratedTemplate(spread, tpl, page, {
theme: ctx.theme ?? null,
words: ctx.words,
travel: ctx.travel,
locale: ctx.locale,
...(newId ? { newId } : {}),
});
}
/** Ids for a card's drawing that stay the same from one render to the next, so its keys do too. */
export function previewIds(prefix: string): (kind: string) => string {
let n = 0;
return (kind) => `${prefix}-${kind}${(n += 1)}`;
}
/** How many frames a laid page is left waiting for a picture in. */
export function emptyFrames(spread: BookSpread): number {
return spread.elements.filter((e) => e.kind === 'photo' && e.photoId == null).length;
}
@@ -14,11 +14,11 @@ import { Check, ImageIcon, RotateCcw } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import '../../../styles/studio-theme.css'; import '../../../styles/studio-theme.css';
import { useToast } from '../../shared/Toast'; import { useToast } from '../../shared/Toast';
import { fontStack } from '../bookFonts';
import { Choice, Line, Picks, Section, Switch } from '../StudioControls'; import { Choice, Line, Picks, Section, Switch } from '../StudioControls';
import { StudioFontPicker } from '../StudioFontPicker'; import { StudioFontPicker } from '../StudioFontPicker';
import { PanelHead } from '../StudioPanelHead'; import { PanelHead, Shelf } from '../StudioPanelHead';
import type { StudioT } from '../StudioSidebar'; import type { StudioT } from '../StudioSidebar';
import { ThemeSheet } from './ThemeSheet';
import { useStudioTheme } from './useStudioTheme'; import { useStudioTheme } from './useStudioTheme';
/** /**
@@ -46,7 +46,6 @@ function ThemeCard({
t: StudioT; t: StudioT;
}) { }) {
const display = useMemo(() => bookThemeOf(preset.id).fonts.display, [preset.id]); const display = useMemo(() => bookThemeOf(preset.id).fonts.display, [preset.id]);
const { palette } = preset;
return ( return (
<button <button
type="button" type="button"
@@ -55,23 +54,7 @@ function ThemeCard({
onClick={onPick} onClick={onPick}
title={t(preset.descrKey)} title={t(preset.descrKey)}
> >
<span className="st-theme-sheet" style={{ background: palette.paper }}> <ThemeSheet palette={preset.palette} display={display} />
<span
className="st-theme-word"
style={{
fontFamily: fontStack(display.font),
color: palette.ink,
fontStyle: display.italic ? 'italic' : undefined,
}}
>
Aa
</span>
<span className="st-theme-dots" aria-hidden>
{[palette.accent, palette.accent2, palette.accent3].map((c, i) => (
<span key={i} style={{ background: c }} />
))}
</span>
</span>
<span className="st-theme-name"> <span className="st-theme-name">
<span>{t(preset.labelKey)}</span> <span>{t(preset.labelKey)}</span>
{inUse && ( {inUse && (
@@ -165,26 +148,27 @@ export function StudioThemePanel({ t }: { t: StudioT }) {
<div className="st-panel-scroll st-theme"> <div className="st-panel-scroll st-theme">
<p className="st-hint">{t('studio.theme.intro')}</p> <p className="st-hint">{t('studio.theme.intro')}</p>
<div className="st-section-label">{t('studio.theme.themes')}</div> <Shelf label={t('studio.theme.themes')}>
<div className="st-theme-grid"> <div className="st-theme-grid">
{BOOK_THEME_PRESETS.map((preset) => ( {BOOK_THEME_PRESETS.map((preset) => (
<ThemeCard <ThemeCard
key={preset.id} key={preset.id}
preset={preset} preset={preset}
on={draft.id === preset.id} on={draft.id === preset.id}
inUse={theme.current?.id === preset.id} inUse={theme.current?.id === preset.id}
onPick={() => theme.pick(preset.id)} onPick={() => theme.pick(preset.id)}
t={t} t={t}
/> />
))} ))}
</div> </div>
{theme.edited && ( {theme.edited && (
<button type="button" className="st-theme-reset" onClick={theme.reset}> <button type="button" className="st-theme-reset" onClick={theme.reset}>
<RotateCcw size={13} /> <RotateCcw size={13} />
{t('studio.theme.reset')} {t('studio.theme.reset')}
</button> </button>
)} )}
</Shelf>
<Section label={t('studio.theme.palette')}> <Section label={t('studio.theme.palette')}>
<div className="st-theme-palette"> <div className="st-theme-palette">
@@ -0,0 +1,35 @@
import type { BookPalette, BookThemeFont } from '@trek/shared';
import '../../../styles/studio-theme.css';
import { fontStack } from '../bookFonts';
/**
* A theme as a little page: its paper, "Aa" in its display face and ink, and
* its three accents. The Theme panel's cards and the inspector's summary of the
* book's theme draw it alike, so the theme a book has looks like the card it
* was picked from.
*
* The colours are the theme's own, set inline from the data: the sheet shows
* what the theme offers, which no token of the app's could.
*/
export function ThemeSheet({ palette, display }: { palette: BookPalette; display: BookThemeFont }) {
return (
<span className="st-theme-sheet" style={{ background: palette.paper }} aria-hidden>
<span
className="st-theme-word"
style={{
fontFamily: fontStack(display.font),
color: palette.ink,
fontStyle: display.italic ? 'italic' : undefined,
}}
>
Aa
</span>
<span className="st-theme-dots">
{[palette.accent, palette.accent2, palette.accent3].map((c, i) => (
<span key={i} style={{ background: c }} />
))}
</span>
</span>
);
}
@@ -2,6 +2,7 @@ import {
BOOK_THEME_IDS, BOOK_THEME_IDS,
bookPaletteFromPhotos, bookPaletteFromPhotos,
bookThemeOf, bookThemeOf,
compareCodeUnits,
setBookTheme, setBookTheme,
type BookFontSlot, type BookFontSlot,
type BookPaletteKey, type BookPaletteKey,
@@ -42,6 +43,15 @@ export function themeRequest(draft: BookTheme, spreadIds: string[] | undefined,
}; };
} }
/** A theme as text, its keys in one order at every depth, so the same theme read twice compares equal. */
function themeKey(theme: BookTheme | undefined): string {
if (!theme) return '';
return JSON.stringify(theme, (_key, value: unknown) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => compareCodeUnits(a, b)));
});
}
/** Whether a draft differs from the built-in theme it is named after. */ /** Whether a draft differs from the built-in theme it is named after. */
export function isEdited(draft: BookTheme): boolean { export function isEdited(draft: BookTheme): boolean {
if (!draft.id) return false; if (!draft.id) return false;
@@ -60,10 +70,13 @@ export function useStudioTheme() {
const [reading, setReading] = useState(false); const [reading, setReading] = useState(false);
// The book's theme changed under the panel (applied, undone, another editor): the draft follows it, // The book's theme changed under the panel (applied, undone, another editor): the draft follows it,
// set while rendering rather than in an effect, so the panel never paints the old draft first. // set while rendering rather than in an effect, so the panel never paints the old draft first. Compared
const [seen, setSeen] = useState(current); // by what it says, not by identity: a save from elsewhere reads the same theme in anew, and must not
if (seen !== current) { // throw away colours being tried here.
setSeen(current); const currentKey = useMemo(() => themeKey(current), [current]);
const [seen, setSeen] = useState(currentKey);
if (seen !== currentKey) {
setSeen(currentKey);
if (current) setDraft(current); if (current) setDraft(current);
} }
+12 -6
View File
@@ -9,8 +9,12 @@ import { useStudioStore } from '../../store/studioStore';
* Every shortcut here is one a design tool has taught people already, and none * Every shortcut here is one a design tool has taught people already, and none
* of them takes a key away from the browser while somebody is typing: in a * of them takes a key away from the browser while somebody is typing: in a
* field, a text area or a text being edited on the page, the keys are the * field, a text area or a text being edited on the page, the keys are the
* field's. Undo and redo are the Studio page's own (useJourneyStudio.ts), and * field's. Nor from a control that takes the key itself: a gradient stop,
* every action below is one undo step there. * the angle dial or a row of the layers tree prevents its default, and the
* assistant's window stops its keys from reaching the page, so Delete on a
* focused stop removes the stop, not the shape. Undo and redo are the Studio
* page's own (useJourneyStudio.ts), and every action below is one undo step
* there.
* *
* - Delete or Backspace: remove what is selected and not locked. * - Delete or Backspace: remove what is selected and not locked.
* - Ctrl+C, Ctrl+V: copy and paste elements, onto any page (#2316). * - Ctrl+C, Ctrl+V: copy and paste elements, onto any page (#2316).
@@ -134,7 +138,7 @@ export function useStudioKeys(opts: { spreadIndex: number; editing: boolean }) {
}; };
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (editing || isTypingTarget(e.target)) return; if (editing || e.defaultPrevented || isTypingTarget(e.target)) return;
const store = useStudioStore.getState(); const store = useStudioStore.getState();
// Read when the key comes, not when the effect ran: a nudge changes the // Read when the key comes, not when the effect ran: a nudge changes the
// page, and an effect that re-ran on it would close the gesture each step. // page, and an effect that re-ran on it would close the gesture each step.
@@ -164,13 +168,15 @@ export function useStudioKeys(opts: { spreadIndex: number; editing: boolean }) {
if (ARROWS[e.key]) stopNudge(); if (ARROWS[e.key]) stopNudge();
}; };
// Capture, so a step out of a group is decided before the page's Escape clears the selection. // On the document as the key bubbles: after every control and the assistant's window had it (React
window.addEventListener('keydown', onKey, true); // listens below the document), and before the page's Escape on the window, so a step out of a group
// is decided before the page clears the selection.
document.addEventListener('keydown', onKey);
window.addEventListener('keyup', onKeyUp, true); window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('blur', stopNudge); window.addEventListener('blur', stopNudge);
return () => { return () => {
stopNudge(); stopNudge();
window.removeEventListener('keydown', onKey, true); document.removeEventListener('keydown', onKey);
window.removeEventListener('keyup', onKeyUp, true); window.removeEventListener('keyup', onKeyUp, true);
window.removeEventListener('blur', stopNudge); window.removeEventListener('blur', stopNudge);
}; };
@@ -64,14 +64,15 @@ export async function findRoundHikes(
): Promise<void> { ): Promise<void> {
const state = await searchRoundLoops(request.start, request.targetM, (spots) => askLoop(request, spots, signal), { const state = await searchRoundLoops(request.start, request.targetM, (spots) => askLoop(request, spots, signal), {
signal, signal,
onStep: ({ found, asked }) => report({ phase: 'searching', candidates: found.map(candidateOf), asked }), onStep: ({ found, asked }) =>
report({ phase: 'searching', candidates: found.map((loop, i) => candidateOf(loop, i)), asked }),
}); });
if (signal.aborted) return; if (signal.aborted) return;
if (state.answered === 0) { if (state.answered === 0) {
report({ phase: 'failed', candidates: [], asked: state.asked }); report({ phase: 'failed', candidates: [], asked: state.asked });
return; return;
} }
const found = state.found.map(candidateOf); const found = state.found.map((loop, i) => candidateOf(loop, i));
let candidates = [...found]; let candidates = [...found];
report({ phase: 'done', candidates, asked: state.asked }); report({ phase: 'done', candidates, asked: state.asked });
for (const round of found) { for (const round of found) {
@@ -1,6 +1,7 @@
import { act, renderHook } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react';
import type { TourListItem } from '@trek/shared'; import type { TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OnlineOnlyError } from '../../repo/onlineOnly';
import type { TourPlannerController } from './planner/useTourPlanner'; import type { TourPlannerController } from './planner/useTourPlanner';
import { useRecordedTrack } from './useRecordedTrack'; import { useRecordedTrack } from './useRecordedTrack';
@@ -87,4 +88,21 @@ describe('useRecordedTrack', () => {
expect(controller.returnToNeutral).not.toHaveBeenCalled(); expect(controller.returnToNeutral).not.toHaveBeenCalled();
expect(onRestored).not.toHaveBeenCalled(); expect(onRestored).not.toHaveBeenCalled();
}); });
it('says it needs a connection when it is offline or behind an edit of the tour still waiting', async () => {
repo.restoreRecorded.mockRejectedValueOnce(new OnlineOnlyError());
const controller = planner(7);
const onRestored = vi.fn();
const { result } = renderHook(() =>
useRecordedTrack({ tripId: 1, planner: controller, tours: [replaced], canEdit: true, onRestored })
);
await act(async () => {
await result.current.restore();
});
expect(toast.error).toHaveBeenCalledWith('tours.recorded.offline');
expect(controller.returnToNeutral).not.toHaveBeenCalled();
expect(onRestored).not.toHaveBeenCalled();
});
}); });
@@ -1,6 +1,7 @@
import type { TourListItem } from '@trek/shared'; import type { TourListItem } from '@trek/shared';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import { OnlineOnlyError } from '../../repo/onlineOnly';
import { tourRepo } from '../../repo/tourRepo'; import { tourRepo } from '../../repo/tourRepo';
import { useToast } from '../shared/Toast'; import { useToast } from '../shared/Toast';
import type { TourPlannerController } from './planner/useTourPlanner'; import type { TourPlannerController } from './planner/useTourPlanner';
@@ -36,8 +37,9 @@ export function useRecordedTrack({ tripId, planner, tours, canEdit, onRestored }
returnToNeutral(); returnToNeutral();
await onRestored(result.tour); await onRestored(result.tour);
toast.success(t('tours.recorded.restored')); toast.success(t('tours.recorded.restored'));
} catch { } catch (err: unknown) {
toast.error(t('tours.recorded.failed')); // Offline, or behind an edit of the tour still waiting to be sent: said as such, not as a failure.
toast.error(t(err instanceof OnlineOnlyError ? 'tours.recorded.offline' : 'tours.recorded.failed'));
} finally { } finally {
setRestoring(false); setRestoring(false);
} }
@@ -1,4 +1,4 @@
import { ReactNode } from 'react' import { ReactNode, useEffect, useLayoutEffect, useRef } from 'react'
interface MDropdownPanelProps { interface MDropdownPanelProps {
open: boolean open: boolean
@@ -12,9 +12,36 @@ interface MDropdownPanelProps {
* Floating popover panel (user menu, filter dropdowns): heavy glass, r20, * Floating popover panel (user menu, filter dropdowns): heavy glass, r20,
* closed by a transparent full-screen backdrop — no dim, unlike sheets. * closed by a transparent full-screen backdrop — no dim, unlike sheets.
* Layered above the scrolling content but below the bottom nav (z-40) and * Layered above the scrolling content but below the bottom nav (z-40) and
* sheets (z-60), matching the design's stacking order. * sheets (z-60), matching the design's stacking order. Opened, it takes the
* focus onto its first button and Escape closes it; closed, the focus goes
* back to the button it was opened from, unless a pick moved it on.
*/ */
export default function MDropdownPanel({ open, onClose, className = '', children }: MDropdownPanelProps) { export default function MDropdownPanel({ open, onClose, className = '', children }: MDropdownPanelProps) {
const panelRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(onClose)
useLayoutEffect(() => {
onCloseRef.current = onClose
})
useEffect(() => {
if (!open) return
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
panelRef.current?.querySelector<HTMLElement>('button:not(:disabled)')?.focus({ preventScroll: true })
// Captured and stopped, so the key closes the panel and nothing under it.
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.stopPropagation()
onCloseRef.current()
}
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('keydown', onKey, true)
// The focused button left with the panel: the focus fell back to the page, and goes to the opener.
const lost = document.activeElement === null || document.activeElement === document.body
if (lost && opener?.isConnected) opener.focus({ preventScroll: true })
}
}, [open])
if (!open) return null if (!open) return null
return ( return (
@@ -23,6 +50,7 @@ export default function MDropdownPanel({ open, onClose, className = '', children
no role of its own; the panel's own controls are the keyboard path. */} no role of its own; the panel's own controls are the keyboard path. */}
<div className="fixed inset-0 z-[35]" role="presentation" onClick={onClose} /> <div className="fixed inset-0 z-[35]" role="presentation" onClick={onClose} />
<div <div
ref={panelRef}
className={`m-pop-in fixed z-[36] w-[228px] rounded-[20px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[6px] text-m-ink shadow-[0_24px_60px_-20px_rgba(0,0,0,.5)] backdrop-blur-[30px] backdrop-saturate-[1.8] ${className}`} className={`m-pop-in fixed z-[36] w-[228px] rounded-[20px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[6px] text-m-ink shadow-[0_24px_60px_-20px_rgba(0,0,0,.5)] backdrop-blur-[30px] backdrop-saturate-[1.8] ${className}`}
> >
{children} {children}
+9 -1
View File
@@ -1,4 +1,4 @@
import { ReactNode } from 'react' import { type AriaAttributes, ReactNode } from 'react'
interface MIconBtnProps { interface MIconBtnProps {
onClick?: () => void onClick?: () => void
@@ -10,6 +10,10 @@ interface MIconBtnProps {
className?: string className?: string
/** Dimmed and inert. For a button whose position should stay put even when it cannot act. */ /** Dimmed and inert. For a button whose position should stay put even when it cannot act. */
disabled?: boolean disabled?: boolean
/** For a button that opens a panel: whether it is open now, said to a screen reader. */
ariaExpanded?: boolean
/** For a button that opens a panel: what kind it is (`menu` for a menu). */
ariaHaspopup?: AriaAttributes['aria-haspopup']
children: ReactNode children: ReactNode
} }
@@ -21,6 +25,8 @@ export default function MIconBtn({
size = 38, size = 38,
className = '', className = '',
disabled = false, disabled = false,
ariaExpanded,
ariaHaspopup,
children, children,
}: MIconBtnProps) { }: MIconBtnProps) {
return ( return (
@@ -28,6 +34,8 @@ export default function MIconBtn({
type="button" type="button"
onClick={onClick} onClick={onClick}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-expanded={ariaExpanded}
aria-haspopup={ariaHaspopup}
disabled={disabled} disabled={disabled}
className={`relative flex flex-none items-center justify-center rounded-full text-m-ink disabled:opacity-35 ${ className={`relative flex flex-none items-center justify-center rounded-full text-m-ink disabled:opacity-35 ${
variant === 'glass' variant === 'glass'
@@ -8,6 +8,8 @@ interface MListRowProps {
danger?: boolean danger?: boolean
onClick?: () => void onClick?: () => void
className?: string className?: string
/** The row is the one open now (a chat in a history list), said so to a screen reader too. */
current?: boolean
} }
/** Menu/list row (user menu style): icon 16 in --m-muted, 13.5px semibold label. */ /** Menu/list row (user menu style): icon 16 in --m-muted, 13.5px semibold label. */
@@ -18,11 +20,13 @@ export default function MListRow({
danger = false, danger = false,
onClick, onClick,
className = '', className = '',
current = false,
}: MListRowProps) { }: MListRowProps) {
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
aria-current={current || undefined}
className={`flex w-full items-center gap-[11px] rounded-xl px-[10px] py-[11px] text-start text-[0.84375rem] font-semibold active:bg-[color:var(--m-ic)] ${ className={`flex w-full items-center gap-[11px] rounded-xl px-[10px] py-[11px] text-start text-[0.84375rem] font-semibold active:bg-[color:var(--m-ic)] ${
danger ? 'text-[color:var(--m-st-danger)]' : 'text-m-ink' danger ? 'text-[color:var(--m-st-danger)]' : 'text-m-ink'
} ${className}`} } ${className}`}
+3 -28
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { lockBodyScroll } from '../../utils/bodyScrollLock' import { lockBodyScroll } from '../../utils/bodyScrollLock'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useDiscardGuard } from '../../components/shared/useDiscardGuard' import { useDiscardGuard } from '../../components/shared/useDiscardGuard'
import { keepTabInside, useModalFocus } from './modalFocus'
export type MSheetVariant = 'card' | 'bottom' | 'drawer' export type MSheetVariant = 'card' | 'bottom' | 'drawer'
export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque' export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque'
@@ -34,9 +35,6 @@ const EXIT_MS = 280
const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold text-white' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold text-white' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
const POSITION: Record<MSheetVariant, string> = { const POSITION: Record<MSheetVariant, string> = {
card: 'absolute inset-x-[14px] top-1/2 -translate-y-1/2', card: 'absolute inset-x-[14px] top-1/2 -translate-y-1/2',
bottom: 'absolute inset-x-4 bottom-[calc(var(--bottom-nav-h,84px)+16px)]', bottom: 'absolute inset-x-4 bottom-[calc(var(--bottom-nav-h,84px)+16px)]',
@@ -128,12 +126,7 @@ export default function MSheet({
}, [open]) }, [open])
// Move focus into the dialog on open, hand it back on close. // Move focus into the dialog on open, hand it back on close.
useEffect(() => { useModalFocus(panelRef, open && rendered)
if (!open || !rendered) return
const previous = document.activeElement as HTMLElement | null
panelRef.current?.focus()
return () => previous?.focus()
}, [open, rendered])
if (!rendered) return null if (!rendered) return null
@@ -164,25 +157,7 @@ export default function MSheet({
} }
// Keep Tab inside the dialog while it is open. // Keep Tab inside the dialog while it is open.
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => keepTabInside(e, panelRef.current)
if (e.key !== 'Tab') return
const panel = panelRef.current
if (!panel) return
const focusable = panel.querySelectorAll<HTMLElement>(FOCUSABLE)
if (focusable.length === 0) {
e.preventDefault()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
const enterClass = variant === 'drawer' ? 'm-drawer-in' : 'm-sheet-in' const enterClass = variant === 'drawer' ? 'm-drawer-in' : 'm-sheet-in'
const exitClass = variant === 'drawer' ? 'm-drawer-out' : 'm-sheet-out' const exitClass = variant === 'drawer' ? 'm-drawer-out' : 'm-sheet-out'
@@ -0,0 +1,41 @@
import { type KeyboardEvent, type RefObject, useEffect } from 'react';
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Keeps Tab inside a modal panel: past its last control it goes round to the
* first, and Shift+Tab from the first one (or from the panel itself) to the
* last. A panel with nothing to focus keeps the focus where it is.
*/
export function keepTabInside(e: KeyboardEvent, panel: HTMLElement | null): void {
if (e.key !== 'Tab' || !panel) return;
const focusable = panel.querySelectorAll<HTMLElement>(FOCUSABLE);
if (focusable.length === 0) {
e.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
/**
* While `shown`, the focus sits on the modal panel (which takes it with
* `tabIndex={-1}`); once it is no longer shown, the focus goes back to where
* it was before.
*/
export function useModalFocus(panel: RefObject<HTMLElement | null>, shown: boolean): void {
useEffect(() => {
if (!shown) return;
const previous = document.activeElement as HTMLElement | null;
panel.current?.focus();
return () => previous?.focus();
}, [panel, shown]);
}

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