From bdf5767a5fb4ab0112b994af2142243096f2ddbf Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 24 Oct 2026 01:47:53 +0000 Subject: [PATCH] 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. --- client/e2e/help/journey.guide.ts | 4 +- client/scripts/eslint-baseline.json | 14 +- client/scripts/i18n-keys.mjs | 5 - client/scripts/layers-baseline.json | 2 +- client/scripts/rtl-baseline.json | 1 - client/scripts/rtl-disable-baseline.json | 1 + client/scripts/size-baseline.json | 2 +- client/scripts/strict-baseline.json | 1 - client/scripts/theme-baseline.json | 3 +- .../Studio/StudioArrangeSection.tsx | 211 +++-- .../components/Studio/StudioContentFilter.tsx | 9 +- .../src/components/Studio/StudioControls.tsx | 43 +- .../components/Studio/StudioEdgeControls.tsx | 13 +- .../Studio/StudioElementsPanel.test.tsx | 113 +++ .../components/Studio/StudioElementsPanel.tsx | 557 +++++------ .../src/components/Studio/StudioInspector.tsx | 869 ++++-------------- .../Studio/StudioLayersPanel.test.tsx | 3 +- .../components/Studio/StudioLayersPanel.tsx | 3 +- .../components/Studio/StudioLineInspector.tsx | 105 ++- .../src/components/Studio/StudioLineShelf.tsx | 18 +- .../Studio/StudioPanelHead.test.tsx | 52 ++ .../src/components/Studio/StudioPanelHead.tsx | 58 +- client/src/components/Studio/StudioSearch.tsx | 46 + .../components/Studio/StudioShapePaint.tsx | 112 ++- client/src/components/Studio/StudioShell.tsx | 6 + .../src/components/Studio/StudioSidebar.tsx | 738 +-------------- .../Studio/StudioStickerInspector.tsx | 64 +- .../components/Studio/StudioStickersPanel.tsx | 48 +- .../src/components/Studio/StudioTabs.test.tsx | 88 ++ client/src/components/Studio/StudioTabs.tsx | 114 +++ .../Studio/StudioTextEffects.test.tsx | 20 + .../components/Studio/StudioTextEffects.tsx | 11 +- .../Studio/StudioTextStyles.test.tsx | 4 +- .../components/Studio/StudioTextStyles.tsx | 6 +- .../Studio/StudioTravelInspector.tsx | 103 ++- .../components/Studio/StudioTravelPanel.tsx | 42 +- .../components/Studio/inspector/FocalPad.tsx | 67 ++ .../StudioDocumentInspector.test.tsx | 88 ++ .../inspector/StudioDocumentInspector.tsx | 165 ++++ .../Studio/inspector/StudioIconInspector.tsx | 91 ++ .../Studio/inspector/StudioPhotoInspector.tsx | 199 ++++ .../inspector/StudioPositionSection.tsx | 114 +++ .../Studio/inspector/StudioShapeInspector.tsx | 71 ++ .../Studio/inspector/StudioTextInspector.tsx | 199 ++++ .../Studio/inspector/inspectorModel.test.ts | 109 +++ .../Studio/inspector/inspectorModel.ts | 88 ++ .../Studio/inspector/inspectorOrder.test.tsx | 320 +++++++ client/src/components/Studio/pagePresets.ts | 18 +- .../Studio/photo/StudioPhotoLook.tsx | 3 +- client/src/components/Studio/photoFilter.ts | 18 +- .../Studio/sidebar/StudioContentPanel.tsx | 243 +++++ .../Studio/sidebar/StudioEntryCard.tsx | 157 ++++ .../Studio/sidebar/StudioIconShelf.tsx | 98 ++ .../Studio/sidebar/StudioPagesPanel.tsx | 227 +++++ .../Studio/sidebar/StudioRail.test.tsx | 171 ++++ .../components/Studio/sidebar/StudioRail.tsx | 77 ++ .../Studio/sidebar/contentDrops.test.ts | 108 +++ .../components/Studio/sidebar/contentDrops.ts | 152 +++ .../components/Studio/sidebar/railSections.ts | 42 + .../Studio/sidebar/useContentUpload.ts | 94 ++ client/src/components/Studio/studioOptions.ts | 32 + .../components/Studio/studioRoving.test.ts | 58 ++ client/src/components/Studio/studioRoving.ts | 37 + .../Studio/theme/StudioThemePanel.tsx | 62 +- .../components/Studio/theme/ThemeSheet.tsx | 35 + client/src/styles/studio-elements.css | 12 - client/src/styles/studio-layers.css | 19 - client/src/styles/studio-paint.css | 8 +- client/src/styles/studio-panels.css | 334 +++++++ client/src/styles/studio-photo.css | 23 +- client/src/styles/studio-theme.css | 28 +- client/src/styles/studio.css | 286 +++--- .../tests/unit/studio/contentPhotos.test.tsx | 19 +- client/tests/unit/studio/entryChips.test.tsx | 2 +- client/tests/unit/studio/inspector.test.tsx | 53 +- .../unit/studio/inspectorTravel.test.tsx | 36 +- client/tests/unit/studio/travelStops.test.tsx | 4 +- shared/src/i18n/ar/index.ts | 2 + shared/src/i18n/ar/journey.ts | 18 - shared/src/i18n/ar/studioPanels.ts | 12 + shared/src/i18n/ar/studioStickers.ts | 1 - shared/src/i18n/az/index.ts | 2 + shared/src/i18n/az/journey.ts | 18 - shared/src/i18n/az/studioPanels.ts | 12 + shared/src/i18n/az/studioStickers.ts | 1 - shared/src/i18n/br/index.ts | 2 + shared/src/i18n/br/journey.ts | 18 - shared/src/i18n/br/studioPanels.ts | 12 + shared/src/i18n/br/studioStickers.ts | 1 - shared/src/i18n/ca/index.ts | 2 + shared/src/i18n/ca/journey.ts | 18 - shared/src/i18n/ca/studioPanels.ts | 12 + shared/src/i18n/ca/studioStickers.ts | 1 - shared/src/i18n/cs/index.ts | 2 + shared/src/i18n/cs/journey.ts | 18 - shared/src/i18n/cs/studioPanels.ts | 12 + shared/src/i18n/cs/studioStickers.ts | 1 - shared/src/i18n/de/index.ts | 2 + shared/src/i18n/de/journey.ts | 18 - shared/src/i18n/de/studioPanels.ts | 12 + shared/src/i18n/de/studioStickers.ts | 1 - shared/src/i18n/en/index.ts | 2 + shared/src/i18n/en/journey.ts | 18 - shared/src/i18n/en/studioPanels.ts | 12 + shared/src/i18n/en/studioStickers.ts | 1 - shared/src/i18n/es/index.ts | 2 + shared/src/i18n/es/journey.ts | 18 - shared/src/i18n/es/studioPanels.ts | 12 + shared/src/i18n/es/studioStickers.ts | 1 - shared/src/i18n/et/index.ts | 2 + shared/src/i18n/et/journey.ts | 18 - shared/src/i18n/et/studioPanels.ts | 12 + shared/src/i18n/et/studioStickers.ts | 1 - shared/src/i18n/fr/index.ts | 2 + shared/src/i18n/fr/journey.ts | 18 - shared/src/i18n/fr/studioPanels.ts | 12 + shared/src/i18n/fr/studioStickers.ts | 1 - shared/src/i18n/gr/index.ts | 2 + shared/src/i18n/gr/journey.ts | 18 - shared/src/i18n/gr/studioPanels.ts | 12 + shared/src/i18n/gr/studioStickers.ts | 1 - shared/src/i18n/hu/index.ts | 2 + shared/src/i18n/hu/journey.ts | 18 - shared/src/i18n/hu/studioPanels.ts | 12 + shared/src/i18n/hu/studioStickers.ts | 1 - shared/src/i18n/id/index.ts | 2 + shared/src/i18n/id/journey.ts | 18 - shared/src/i18n/id/studioPanels.ts | 12 + shared/src/i18n/id/studioStickers.ts | 1 - shared/src/i18n/it/index.ts | 2 + shared/src/i18n/it/journey.ts | 18 - shared/src/i18n/it/studioPanels.ts | 12 + shared/src/i18n/it/studioStickers.ts | 1 - shared/src/i18n/ja/index.ts | 2 + shared/src/i18n/ja/journey.ts | 18 - shared/src/i18n/ja/studioPanels.ts | 12 + shared/src/i18n/ja/studioStickers.ts | 1 - shared/src/i18n/ko/index.ts | 2 + shared/src/i18n/ko/journey.ts | 18 - shared/src/i18n/ko/studioPanels.ts | 12 + shared/src/i18n/ko/studioStickers.ts | 1 - shared/src/i18n/nl/index.ts | 2 + shared/src/i18n/nl/journey.ts | 18 - shared/src/i18n/nl/studioPanels.ts | 12 + shared/src/i18n/nl/studioStickers.ts | 1 - shared/src/i18n/pl/index.ts | 2 + shared/src/i18n/pl/journey.ts | 18 - shared/src/i18n/pl/studioPanels.ts | 12 + shared/src/i18n/pl/studioStickers.ts | 1 - shared/src/i18n/ru/index.ts | 2 + shared/src/i18n/ru/journey.ts | 18 - shared/src/i18n/ru/studioPanels.ts | 12 + shared/src/i18n/ru/studioStickers.ts | 1 - shared/src/i18n/sk/index.ts | 2 + shared/src/i18n/sk/journey.ts | 18 - shared/src/i18n/sk/studioPanels.ts | 12 + shared/src/i18n/sk/studioStickers.ts | 1 - shared/src/i18n/sv/index.ts | 2 + shared/src/i18n/sv/journey.ts | 18 - shared/src/i18n/sv/studioPanels.ts | 12 + shared/src/i18n/sv/studioStickers.ts | 1 - shared/src/i18n/th/index.ts | 2 + shared/src/i18n/th/journey.ts | 17 - shared/src/i18n/th/studioPanels.ts | 12 + shared/src/i18n/th/studioStickers.ts | 1 - shared/src/i18n/tr/index.ts | 2 + shared/src/i18n/tr/journey.ts | 18 - shared/src/i18n/tr/studioPanels.ts | 12 + shared/src/i18n/tr/studioStickers.ts | 1 - shared/src/i18n/uk/index.ts | 2 + shared/src/i18n/uk/journey.ts | 18 - shared/src/i18n/uk/studioPanels.ts | 12 + shared/src/i18n/uk/studioStickers.ts | 1 - shared/src/i18n/vi/index.ts | 2 + shared/src/i18n/vi/journey.ts | 18 - shared/src/i18n/vi/studioPanels.ts | 12 + shared/src/i18n/vi/studioStickers.ts | 1 - shared/src/i18n/zh-TW/index.ts | 2 + shared/src/i18n/zh-TW/journey.ts | 18 - shared/src/i18n/zh-TW/studioPanels.ts | 12 + shared/src/i18n/zh-TW/studioStickers.ts | 1 - shared/src/i18n/zh/index.ts | 2 + shared/src/i18n/zh/journey.ts | 18 - shared/src/i18n/zh/studioPanels.ts | 12 + shared/src/i18n/zh/studioStickers.ts | 1 - 185 files changed, 5457 insertions(+), 2889 deletions(-) create mode 100644 client/src/components/Studio/StudioElementsPanel.test.tsx create mode 100644 client/src/components/Studio/StudioPanelHead.test.tsx create mode 100644 client/src/components/Studio/StudioSearch.tsx create mode 100644 client/src/components/Studio/StudioTabs.test.tsx create mode 100644 client/src/components/Studio/StudioTabs.tsx create mode 100644 client/src/components/Studio/inspector/FocalPad.tsx create mode 100644 client/src/components/Studio/inspector/StudioDocumentInspector.test.tsx create mode 100644 client/src/components/Studio/inspector/StudioDocumentInspector.tsx create mode 100644 client/src/components/Studio/inspector/StudioIconInspector.tsx create mode 100644 client/src/components/Studio/inspector/StudioPhotoInspector.tsx create mode 100644 client/src/components/Studio/inspector/StudioPositionSection.tsx create mode 100644 client/src/components/Studio/inspector/StudioShapeInspector.tsx create mode 100644 client/src/components/Studio/inspector/StudioTextInspector.tsx create mode 100644 client/src/components/Studio/inspector/inspectorModel.test.ts create mode 100644 client/src/components/Studio/inspector/inspectorModel.ts create mode 100644 client/src/components/Studio/inspector/inspectorOrder.test.tsx create mode 100644 client/src/components/Studio/sidebar/StudioContentPanel.tsx create mode 100644 client/src/components/Studio/sidebar/StudioEntryCard.tsx create mode 100644 client/src/components/Studio/sidebar/StudioIconShelf.tsx create mode 100644 client/src/components/Studio/sidebar/StudioPagesPanel.tsx create mode 100644 client/src/components/Studio/sidebar/StudioRail.test.tsx create mode 100644 client/src/components/Studio/sidebar/StudioRail.tsx create mode 100644 client/src/components/Studio/sidebar/contentDrops.test.ts create mode 100644 client/src/components/Studio/sidebar/contentDrops.ts create mode 100644 client/src/components/Studio/sidebar/railSections.ts create mode 100644 client/src/components/Studio/sidebar/useContentUpload.ts create mode 100644 client/src/components/Studio/studioOptions.ts create mode 100644 client/src/components/Studio/studioRoving.test.ts create mode 100644 client/src/components/Studio/studioRoving.ts create mode 100644 client/src/components/Studio/theme/ThemeSheet.tsx delete mode 100644 client/src/styles/studio-elements.css create mode 100644 client/src/styles/studio-panels.css create mode 100644 shared/src/i18n/ar/studioPanels.ts create mode 100644 shared/src/i18n/az/studioPanels.ts create mode 100644 shared/src/i18n/br/studioPanels.ts create mode 100644 shared/src/i18n/ca/studioPanels.ts create mode 100644 shared/src/i18n/cs/studioPanels.ts create mode 100644 shared/src/i18n/de/studioPanels.ts create mode 100644 shared/src/i18n/en/studioPanels.ts create mode 100644 shared/src/i18n/es/studioPanels.ts create mode 100644 shared/src/i18n/et/studioPanels.ts create mode 100644 shared/src/i18n/fr/studioPanels.ts create mode 100644 shared/src/i18n/gr/studioPanels.ts create mode 100644 shared/src/i18n/hu/studioPanels.ts create mode 100644 shared/src/i18n/id/studioPanels.ts create mode 100644 shared/src/i18n/it/studioPanels.ts create mode 100644 shared/src/i18n/ja/studioPanels.ts create mode 100644 shared/src/i18n/ko/studioPanels.ts create mode 100644 shared/src/i18n/nl/studioPanels.ts create mode 100644 shared/src/i18n/pl/studioPanels.ts create mode 100644 shared/src/i18n/ru/studioPanels.ts create mode 100644 shared/src/i18n/sk/studioPanels.ts create mode 100644 shared/src/i18n/sv/studioPanels.ts create mode 100644 shared/src/i18n/th/studioPanels.ts create mode 100644 shared/src/i18n/tr/studioPanels.ts create mode 100644 shared/src/i18n/uk/studioPanels.ts create mode 100644 shared/src/i18n/vi/studioPanels.ts create mode 100644 shared/src/i18n/zh-TW/studioPanels.ts create mode 100644 shared/src/i18n/zh/studioPanels.ts diff --git a/client/e2e/help/journey.guide.ts b/client/e2e/help/journey.guide.ts index 03cbea903..40210e72c 100644 --- a/client/e2e/help/journey.guide.ts +++ b/client/e2e/help/journey.guide.ts @@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request, // ── Studio ──────────────────────────────────────────────────────────────────── const rail = (page: Page) => page.locator('.st-rail') -const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true }) +const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true }) const sidePanel = (page: Page) => page.locator('.st-panel.st-side') const inspector = (page: Page) => page.locator('.st-inspector') const studioMenu = (page: Page) => page.locator('.st-menu').last() @@ -1431,7 +1431,7 @@ const SCRIPTS: Record = { }, { prepare: async p => { - await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click() + await sidePanel(p).getByRole('tab', { name: /Entries/ }).click() await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible() await settle(p) }, diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index d670bdc8f..ea5a7bcfd 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -1,20 +1,20 @@ { "src": { - "@typescript-eslint/no-non-null-assertion": 401, - "@typescript-eslint/no-misused-promises": 850, + "@typescript-eslint/no-non-null-assertion": 385, + "@typescript-eslint/no-misused-promises": 849, "no-console": 88, - "complexity": 226, + "complexity": 222, "@typescript-eslint/no-explicit-any": 146, "trek/no-swallowed-catch": 130, - "max-params": 33, + "max-params": 32, "trek/store-without-selector": 67, "react-hooks/refs": 556, "react-hooks/set-state-in-effect": 194, "@typescript-eslint/no-floating-promises": 165, "react-hooks/exhaustive-deps": 116, - "@typescript-eslint/no-unused-vars": 125, + "@typescript-eslint/no-unused-vars": 124, "react-hooks/immutability": 28, - "max-lines-per-function": 132, + "max-lines-per-function": 130, "react-hooks/static-components": 78, "react-hooks/preserve-manual-memoization": 12, "no-useless-assignment": 7, @@ -35,7 +35,7 @@ }, "tests": { "@typescript-eslint/no-explicit-any": 490, - "@typescript-eslint/no-non-null-assertion": 1932, + "@typescript-eslint/no-non-null-assertion": 1931, "@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-unused-vars": 51, "@typescript-eslint/no-floating-promises": 33, diff --git a/client/scripts/i18n-keys.mjs b/client/scripts/i18n-keys.mjs index 2945e3f74..d7b34f6e8 100644 --- a/client/scripts/i18n-keys.mjs +++ b/client/scripts/i18n-keys.mjs @@ -117,11 +117,6 @@ export const DYNAMIC_ALLOWED = [ template: 'settings.${mode}', because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist", }, - ...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({ - file, - template: 'journey.studio.${key}', - because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals', - })), { file: 'mobile/screens/trip/sheets/MBookingFields.tsx', template: 'reservations.${s}', diff --git a/client/scripts/layers-baseline.json b/client/scripts/layers-baseline.json index 3dbefdf81..b18cba06a 100644 --- a/client/scripts/layers-baseline.json +++ b/client/scripts/layers-baseline.json @@ -38,8 +38,8 @@ "src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1, "src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1, "src/components/Studio/StudioShell.tsx": 1, - "src/components/Studio/StudioSidebar.tsx": 1, "src/components/Studio/TravelElements.tsx": 1, + "src/components/Studio/sidebar/StudioEntryCard.tsx": 1, "src/components/Todo/useTodoList.ts": 1, "src/components/shared/ConfirmDialog.tsx": 1, "src/components/shared/CountryFlag.tsx": 1, diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json index 0ec7fad94..8a6f5c759 100644 --- a/client/scripts/rtl-baseline.json +++ b/client/scripts/rtl-baseline.json @@ -56,7 +56,6 @@ "components/Studio/StudioCanvas.tsx": 12, "components/Studio/StudioElementsPanel.tsx": 1, "components/Studio/StudioExport.tsx": 1, - "components/Studio/StudioInspector.tsx": 1, "components/Studio/StudioSavedLayouts.tsx": 1, "components/Studio/TravelElements.tsx": 4, "components/Studio/TravelPreview.tsx": 1, diff --git a/client/scripts/rtl-disable-baseline.json b/client/scripts/rtl-disable-baseline.json index fa750f645..92a17010c 100644 --- a/client/scripts/rtl-disable-baseline.json +++ b/client/scripts/rtl-disable-baseline.json @@ -1,6 +1,7 @@ { "components/Studio/LineHandles.tsx": 1, "components/Studio/StudioSelectionChrome.tsx": 2, + "components/Studio/inspector/FocalPad.tsx": 1, "mobile/components/trek3d/MascotClip.tsx": 1, "pages/tripPlanner/PlanMapStage.tsx": 4 } diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index ca3b3533a..20ecc0adc 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -26,5 +26,5 @@ "src/pages/atlas/useAtlas.ts": 1211, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.test.tsx": 3270, - "src/styles/studio.css": 2057 + "src/styles/studio.css": 2009 } diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json index c315f8a98..d6b35e74f 100644 --- a/client/scripts/strict-baseline.json +++ b/client/scripts/strict-baseline.json @@ -82,7 +82,6 @@ "src/components/Settings/NotificationsTab.test.tsx": 1, "src/components/Settings/PasskeysSection.tsx": 1, "src/components/Settings/PhotoProvidersSection.test.tsx": 2, - "src/components/Studio/StudioInspector.tsx": 1, "src/components/Todo/todoListModel.ts": 1, "src/components/Tours/planner/TourPlannerPanels.tsx": 2, "src/components/Tours/planner/useTourPlanner.ts": 2, diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json index 0d40d5f10..34a2dc24f 100644 --- a/client/scripts/theme-baseline.json +++ b/client/scripts/theme-baseline.json @@ -103,11 +103,10 @@ "components/Studio/PhotoView.tsx": 2, "components/Studio/SpreadView.tsx": 1, "components/Studio/StudioElementsPanel.tsx": 8, - "components/Studio/StudioInspector.tsx": 1, "components/Studio/StudioShell.tsx": 2, - "components/Studio/StudioSidebar.tsx": 2, "components/Studio/TravelElements.tsx": 5, "components/Studio/printSheets.ts": 5, + "components/Studio/sidebar/contentDrops.ts": 2, "components/SystemNotices/ReleaseNoticeVisuals.tsx": 4, "components/SystemNotices/SystemNoticeBanner.tsx": 28, "components/SystemNotices/SystemNoticeModal.tsx": 107, diff --git a/client/src/components/Studio/StudioArrangeSection.tsx b/client/src/components/Studio/StudioArrangeSection.tsx index eea863db1..b08d15a6b 100644 --- a/client/src/components/Studio/StudioArrangeSection.tsx +++ b/client/src/components/Studio/StudioArrangeSection.tsx @@ -9,7 +9,12 @@ import { AlignStartHorizontal, AlignStartVertical, AlignVerticalDistributeCenter, + ArrowDown, + ArrowUp, + ChevronsDown, + ChevronsUp, Group, + Layers, Paintbrush, Pipette, Ungroup, @@ -18,17 +23,32 @@ import { import { useState } from 'react'; import { useStudioStore } from '../../store/studioStore'; +import '../../styles/studio-panels.css'; import { Tooltip } from '../shared/Tooltip'; -import { Choice, Section } from './StudioControls'; +import { Choice, Line, Section } from './StudioControls'; import { useStudioShortcut } from './studioShortcut'; /** - * Align, distribute, group and the style clipboard, for whatever is selected. + * Arrange: where the selection sits among the other things on the page. * - * The align bar lines a multi-selection up within its own box by default, and + * Lining it up and spacing it out, what it lines up against, what lies in + * front of what, grouping, and the style clipboard, in one group at the foot + * of the inspector. These were two groups, Align and Arrange, one under the + * other and both about the selection's place among its neighbours, so the + * stacking buttons were a fold away from the alignment they are used with. + * + * Every line puts its row under its label, across the whole width of the + * panel: the six edges and the two spacings are eight buttons, more than fit + * beside a label. Each row is two sets, the edges and the spacings, the stack + * and the grouping, and a set that no longer fits moves to the next line + * whole, so a narrow panel shows two even rows rather than a ragged one. + * + * The align row lines a multi-selection up within its own box by default, and * a single element (or one whole group) against its page, since there is * nothing else for it to line up with. Groups move as one box throughout - * (shared/src/book/book-arrange.ts). Every press is one undo step. + * (shared/src/book/book-arrange.ts). The Layers button opens the rail's + * Layers panel, the whole stack of the page beside the selection. Every press + * is one undo step. */ const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [ @@ -40,6 +60,13 @@ const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [ { edge: 'bottom', icon: AlignEndHorizontal, labelKey: 'journey.studio.layers.alignBottom' }, ]; +const STACK: { to: 'front' | 'up' | 'down' | 'back'; icon: LucideIcon; labelKey: string }[] = [ + { to: 'front', icon: ChevronsUp, labelKey: 'journey.studio.toFront' }, + { to: 'up', icon: ArrowUp, labelKey: 'journey.studio.forward' }, + { to: 'down', icon: ArrowDown, labelKey: 'journey.studio.backward' }, + { to: 'back', icon: ChevronsDown, labelKey: 'journey.studio.toBack' }, +]; + /** How many things move as one in a selection: a group counts once. */ function unitCount(spread: BookSpread, ids: string[]): number { return distributableCount( @@ -70,12 +97,22 @@ function IconButton({ ); } -export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number; t: (k: string) => string }) { +export function StudioArrangeSection({ + spreadIndex, + t, + onShowLayers, +}: { + spreadIndex: number; + t: (k: string) => string; + /** Opens the Layers panel in the rail; without it the button is left out. */ + onShowLayers?: () => void; +}) { const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]); const selection = useStudioStore((s) => s.selection); const hasStyle = useStudioStore((s) => s.styleClip !== null); const align = useStudioStore((s) => s.align); const distribute = useStudioStore((s) => s.distribute); + const raise = useStudioStore((s) => s.raise); const group = useStudioStore((s) => s.group); const ungroup = useStudioStore((s) => s.ungroup); const copyStyle = useStudioStore((s) => s.copyStyle); @@ -95,76 +132,102 @@ export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number; return ( // Open for a multi-selection, where lining up is what comes next; folded for one element. -
1 ? 'many' : 'one'} label={t('journey.studio.layers.align')} defaultOpen={units > 1}> -
- {ALIGN.map((a) => ( - align(spreadIndex, a.edge, to)} +
1 ? 'many' : 'one'} label={t('journey.studio.arrange')} defaultOpen={units > 1}> +
+ +
+ + {ALIGN.map((a) => ( + align(spreadIndex, a.edge, to)} + /> + ))} + + + distribute(spreadIndex, 'horizontal')} + /> + distribute(spreadIndex, 'vertical')} + /> + +
+
+ + + 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} /> - ))} - - distribute(spreadIndex, 'horizontal')} - /> - distribute(spreadIndex, 'vertical')} - /> -
-
- {t('journey.studio.layers.alignTo')} - - 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} - /> -
-
- {(canGroup || !grouped) && ( - group(spreadIndex)} - /> - )} - {grouped && ( - ungroup(spreadIndex)} - /> - )} - - copyStyle(spreadIndex)} - /> - pasteStyle(spreadIndex)} - /> + + +
+ + {STACK.map((s) => ( + raise(spreadIndex, selection, s.to)} + /> + ))} + + + {(canGroup || !grouped) && ( + group(spreadIndex)} + /> + )} + {grouped && ( + ungroup(spreadIndex)} + /> + )} + {onShowLayers && ( + + )} + +
+
+ +
+ copyStyle(spreadIndex)} + /> + pasteStyle(spreadIndex)} + /> +
+
{asGroup &&

{t('journey.studio.layers.groupHint')}

}
diff --git a/client/src/components/Studio/StudioContentFilter.tsx b/client/src/components/Studio/StudioContentFilter.tsx index bcd1633c6..a89ba9f57 100644 --- a/client/src/components/Studio/StudioContentFilter.tsx +++ b/client/src/components/Studio/StudioContentFilter.tsx @@ -13,9 +13,10 @@ import type { PhotoFilter } from './photoFilter' * heading over the grid rather than as one control among several, and a long * entry name has somewhere to go. * - * The popover is the top bar's format menu, hung from the left edge instead of - * the right: the side panel is 236px wide and a menu anchored right would open - * into the rail. + * The popover is the top bar's format menu, hung from the start edge instead + * of the end: the side panel is 236px wide and a menu anchored at the end would + * open into the rail. A long entry name takes two lines in the chip, and the + * menu shows every name whole. */ export function StudioContentFilter({ source, filter, setFilter, admitted, pageEntryId, t, locale, @@ -79,7 +80,7 @@ export function StudioContentFilter({ aria-expanded={open} title={t('journey.studio.filterPhotos')} > - {label} + {label} {admitted} diff --git a/client/src/components/Studio/StudioControls.tsx b/client/src/components/Studio/StudioControls.tsx index 37e626006..68fb81d94 100644 --- a/client/src/components/Studio/StudioControls.tsx +++ b/client/src/components/Studio/StudioControls.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from 'react' +import { useId, useState, type ReactNode } from 'react' import { Check, ChevronDown } from 'lucide-react' import ToggleSwitch from '../Settings/ToggleSwitch' @@ -31,10 +31,15 @@ import ToggleSwitch from '../Settings/ToggleSwitch' /** * One group of properties. * - * `defaultOpen={false}` is for the groups you set once and rarely return to — - * the frame's numbers, mostly. The state is per mounted section rather than - * remembered: coming back to an element and finding its panel folded up - * differently from last time is worse than opening one group again. + * `defaultOpen={false}` is for the groups you set once and rarely return to: + * the frame's numbers, the effects, the advanced settings of a kind. The + * inspector opens such a group anyway when the element already uses it, so a + * photo with a duotone shows its duotone and a plain one keeps it out of the + * way (inspector/inspectorModel.ts says which groups are in use). The state + * is per mounted section rather than remembered: the inspector mounts its + * groups afresh for every selection, and coming back to an element and finding + * its panel folded up differently from last time is worse than opening one + * group again. */ export function Section({ label, children, defaultOpen = true, hint, badge }: { label: string @@ -51,15 +56,19 @@ export function Section({ label, children, defaultOpen = true, hint, badge }: { badge?: string }) { const [open, setOpen] = useState(defaultOpen) + const body = useId() return (
- +

+ +

{open && ( -
+
{hint &&

{hint}

} {children}
@@ -68,7 +77,17 @@ export function Section({ label, children, defaultOpen = true, hint, badge }: { ) } -/** A labelled line. The label sits left, the control fills what is left. */ +/** + * A labelled line: the label first, the control after it. + * + * The two share the line while they fit and the control moves under the label + * when they do not, so a long label ("Πεζά και κεφαλαία", "Átlátszatlanság") + * pushes its control down a line instead of being cut or squeezing the control + * to nothing. There is no column of a fixed width for the label to fit into; + * short labels sit at a common minimum, so the controls of a group still start + * at one edge. `wide` puts the control under the label always, for a control + * that needs the whole width, like a row of swatches. + */ export function Line({ label, children, wide = false }: { label: string children: ReactNode diff --git a/client/src/components/Studio/StudioEdgeControls.tsx b/client/src/components/Studio/StudioEdgeControls.tsx index ecb2b0dbc..775fc8486 100644 --- a/client/src/components/Studio/StudioEdgeControls.tsx +++ b/client/src/components/Studio/StudioEdgeControls.tsx @@ -7,9 +7,10 @@ import { bookShapeTakesEdge, } from '@trek/shared'; -import { NumField, Section } from './StudioControls'; +import { edgeInUse } from './inspector/inspectorModel'; import { DEFAULT_EDGE_SIZE } from './shapeOutline'; import { edgedPath } from './shapes-edges'; +import { NumField, Section } from './StudioControls'; /** * The edge of a shape or of a photo's cut: a row of tiles, each the edge drawn @@ -50,12 +51,8 @@ export function EdgeSection({ // A heart or a star keeps its drawn line, so there is nothing to offer unless an edge is already set on one. if (!takes && current === 'straight') return null; return ( -
- {!takes && ( -

- {t('journey.studio.edgeUnavailable')} -

- )} +
+ {!takes &&

{t('journey.studio.edgeUnavailable')}

}
{BOOK_EDGES.map((e) => ( - ) -} - /** 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) => void -/** The type tab: the four plain styles, then the lockups of the Text styles shelf. */ -function TextTab({ page, source, t, addText }: { - page: BookPageSetup - source: ShelfSource - t: (k: string) => string - addText: AddText -}) { - return ( - <> -
-
{t('journey.studio.text')}
-
- - - - -
-
- - - - ) -} - -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(null) +/** + * What every tab places, and where: centred on the page that is open, across + * both pages of a spread or on the one page of a cover. + */ +function useElementAdders(page: BookPageSetup) { const addElement = useStudioStore(s => s.addElement) const commit = useStudioStore(s => s.commit) const select = useStudioStore(s => s.select) const active = useStudioStore(s => s.activeSpread) - const doc = useStudioStore(s => s.doc) - const spread = doc?.spreads[active] - const single = !!spread && spread.role !== 'inner' + const role = useStudioStore(s => s.doc?.spreads[s.activeSpread]?.role) + const single = role !== undefined && role !== 'inner' const centre = (w: number, h: number) => { const W = single ? page.pageWidth : page.pageWidth * 2 return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h } } - const addText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra: Partial = {}) => + const addText: AddText = (size, weight, sample, extra = {}) => addElement(active, { id: uid('t'), kind: 'text', frame: centre(page.pageWidth * 0.7, size * 0.5 + 8), rotation: 0, opacity: 1, locked: false, @@ -205,7 +147,7 @@ export function StudioElementsPanel({ page, t, source }: { : own ? centre(own.w, own.h) : centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side), rotation: 0, opacity: 1, locked: false, - // A rule is a shape too — a hairline box rather than its own element type, + // A rule is a shape too: a hairline box rather than its own element type, // so it moves, colours and snaps like everything else on the page. shape: isRule ? 'rect' : shape, fill: opts.outline ? null : '#141414', @@ -218,11 +160,9 @@ export function StudioElementsPanel({ page, t, source }: { } /** - * An icon, placed as a square. - * - * Square because that is what it is: unlike a shape, an icon keeps its - * proportions inside whatever frame it is given, so a rectangle would only - * pad it with air on two sides. + * An icon, placed as a square. Unlike a shape, an icon keeps its proportions + * inside whatever frame it is given, so a rectangle would only pad it with + * air on two sides. */ const addIcon = (name: string) => { const side = Math.min(page.pageWidth, page.pageHeight) * 0.18 @@ -247,240 +187,229 @@ export function StudioElementsPanel({ page, t, source }: { } as BookElement) } - const matchedIcons = useMemo(() => searchIcons(iconQuery), [iconQuery]) - const shownIcons = useMemo(() => matchedIcons.slice(0, iconLimit), [matchedIcons, iconLimit]) + /** A block of empty frames, selected as a group so it moves and resizes as one thing straight away. */ + const addGrid = (grid: (typeof GRIDS)[number]) => { + const els = gridElements(grid, defaultGridBox(page, single)) + commit(d => ({ + ...d, + spreads: d.spreads.map((sp, i) => (i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })), + })) + select(els.map(e => e.id)) + } - /* - * Grow the grid when its foot comes into view. - * - * An observer rather than a scroll handler: the panel is the scroller, the - * foot is the thing that matters, and asking the browser to tell us when they - * meet costs nothing per frame. - */ - useEffect(() => { - const tail = iconTail.current - if (!tail) return - const io = new IntersectionObserver(entries => { - if (entries.some(e => e.isIntersecting)) setIconLimit(n => n + ICON_PAGE) - }) - io.observe(tail) - return () => io.disconnect() - }, [shownIcons.length, matchedIcons.length]) + return { active, single, addElement, addText, addShape, addIcon, addFrame, addGrid } +} + +type Adders = ReturnType + +/** The type tab: the plain styles and the big quotation mark, then the lockups of the Text styles shelf. */ +function TextTab({ page, source, t, addText }: { + page: BookPageSetup + source: ShelfSource + t: (k: string) => string + addText: AddText +}) { + return ( + <> + +
+ + + + + {/* A mark of type rather than a shape: it sets in the face the rest of the page is set in. */} + +
+
+ + + + ) +} + +/** 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 => ( + +
+ {group.id === 'basic' && ( + + )} + {group.shapes.map(shape => ( + + ))} +
+
+ ))} + + ) +} + +/** 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 ( + <> + +
+ {FRAME_STYLE_OPTIONS.map(({ value, labelKey }) => ( + + ))} +
+
+ + +
+ {FRAME_SHAPES.map(shape => ( + + ))} +
+
+ + +
+ {GRIDS.map(grid => ( + + ))} +
+
+ + ) +} + +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') + const tabsId = useId() + const adders = useElementAdders(page) return ( <> -
- - - - - -
+ + idBase={tabsId} + label={t('journey.studio.elements')} + value={tab} + onChange={setTab} + columns={3} + tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))} + /> -
+ {/* - Type first: the plain styles, then the lockups, set titles and marks - drawn with the book's own words (StudioTextStyles.tsx). + The icons and the stickers carry a search of their own, which sits + above their scrolling grid like the Content panel's above its own; + the other tabs are short shelves and scroll as they are. */} - {tab === 'text' && } - - {tab === 'shapes' && ( - <> -
-
{t('journey.studio.lines')}
-
- - - -
-
- - addElement(active, el)} t={t} /> - - {SHAPE_GROUPS.map(group => ( -
-
{t(`journey.studio.shapeGroup.${group.id}`)}
-
- {group.shapes.map(shape => ( - - ))} -
-
- ))} - - )} - - {tab === 'frames' && ( - <> -
-
{t('journey.studio.frameStyles')}
-
- {([ - ['none', 'plainFrame'], - ['polaroid', 'polaroidFrame'], - ['white', 'whiteFrame'], - ['shadow', 'shadowFrame'], - ['film', 'filmFrame'], - ['tape', 'tapeFrame'], - ] as [FrameStyle, string][]).map(([style, key]) => ( + {tab === 'icons' ? ( + + ) : tab === 'stickers' ? ( + + ) : ( +
+ {tab === 'text' && } + {tab === 'shapes' && } + {tab === 'lines' && ( + adders.addElement(adders.active, el)} + t={t} + lead={( - ))} -
-
- -
-
{t('journey.studio.frameShapes')}
-
- {FRAME_SHAPES.map(shape => ( - - ))} -
-

{t('journey.studio.frameHint')}

-
- - )} - - {tab === 'grids' && ( -
-
{t('journey.studio.grids')}
-
- {GRIDS.map(grid => ( - - ))} -
-

{t('journey.studio.gridHint')}

-
- )} - - {tab === 'icons' && ( -
- {/* - 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. - */} -
- - { setIconQuery(e.target.value); setIconLimit(ICON_PAGE) }} - placeholder={t('journey.studio.searchIcons')} - aria-label={t('journey.studio.searchIcons')} - spellCheck={false} + )} /> - {iconQuery && ( - - )} -
- {/* The shelf, until somebody searches. */} - {!iconQuery && ( - <> -
{t('journey.studio.iconsForTravel')}
-
- {FEATURED_ICONS.map(name => ( - - ))} -
-
{t('journey.studio.iconsAll')}
- - )} -
- {shownIcons.map(name => ( - - ))} -
- {/* What the observer below watches for. */} - {shownIcons.length < matchedIcons.length &&
} - {!matchedIcons.length && ( -

{t('journey.studio.noMatches')}

)} + {tab === 'frames' && }
)} -
+ ) } diff --git a/client/src/components/Studio/StudioInspector.tsx b/client/src/components/Studio/StudioInspector.tsx index f856375f5..5ed3ae45e 100644 --- a/client/src/components/Studio/StudioInspector.tsx +++ b/client/src/components/Studio/StudioInspector.tsx @@ -1,36 +1,25 @@ -import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared' -import { - AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp, - ChevronsDown, ChevronsUp, ClipboardCopy, Copy, Italic, Lock, Trash2, Unlock, -} from 'lucide-react' -import { useMemo, useRef, useState } from 'react' +import type { BookElement, BookPageNumbers, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared' +import { withThemeLinks } from '@trek/shared' +import { ClipboardCopy, Copy, Lock, Trash2, Unlock } from 'lucide-react' +import type { ReactNode } from 'react' import { useStudioStore } from '../../store/studioStore' -import { photoSrc } from './bookRender' -import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes' -import { ShapeGlyph } from './StudioElementsPanel' +import { Tooltip } from '../shared/Tooltip' +import '../../styles/studio-panels.css' +import { PanelHead } from './StudioPanelHead' +import { selectionTitle } from './studioLayerRows' +import { StudioArrangeSection } from './StudioArrangeSection' import { TravelInspector } from './StudioTravelInspector' import { StudioStickerInspector } from './StudioStickerInspector' -import { Swatches } from './StudioSwatches' -import { StudioBackgroundSection } from './StudioBackgroundSection' -import { StudioShapePaint } from './StudioShapePaint' -import { MAX_BOOK_TEXT_SIZE, formatBookCoords, withThemeLinks } from '@trek/shared' -import { iconComponent, iconLabel, searchIcons } from './iconLibrary' -import { Choice, Line, NumField, Section, Switch } from './StudioControls' -import { StudioFontPicker } from './StudioFontPicker' -import { WeightLine } from './StudioTypeControls' -import { italicTitle } from './fontPicker' -import { PhotoCropExtras, PhotoLookSections } from './photo/StudioPhotoLook' -import { StudioTextEffects } from './StudioTextEffects' -import type { JourneySource } from './StudioSidebar' -import { Tooltip } from '../shared/Tooltip' -import { StudioArrangeSection } from './StudioArrangeSection' -import { selectionTitle } from './studioLayerRows' -import { EdgeSection } from './StudioEdgeControls' import { LineInspector } from './StudioLineInspector' -import { StudioThemeLink } from './theme/StudioThemeLink' - -/** How many icons the swap grid offers at once. A search narrows it below this. */ -const ICON_CHOICES = 60 +import { StudioDocumentInspector } from './inspector/StudioDocumentInspector' +import { StudioPositionSection } from './inspector/StudioPositionSection' +import { StudioTextInspector } from './inspector/StudioTextInspector' +import { StudioPhotoInspector } from './inspector/StudioPhotoInspector' +import { StudioShapeInspector } from './inspector/StudioShapeInspector' +import { StudioIconInspector } from './inspector/StudioIconInspector' +import { selectionKey } from './inspector/inspectorModel' +import type { RailSection } from './sidebar/railSections' +import type { JourneySource } from './StudioSidebar' /** * Properties of whatever is selected. @@ -42,24 +31,27 @@ const ICON_CHOICES = 60 * out reads as a settings dialog, not as a tool. * * The order is the same for every kind, because the panel is used by muscle - * memory more than by reading: **what it says** first, then **how it looks**, - * then **where it sits**, then **what is in front of what**. A photo's crop and - * a mark's words are the same step in that sequence, so they sit in the same - * place on the panel. + * memory more than by reading: * - * The frame's numbers are folded away by default. They are the least-used group - * in the panel — dragging is how anyone actually positions an element — and - * four number fields left open at the top pushed everything worth looking at - * below the fold. + * 1. **what it is and what it says**: a text's words, a photo's crop, a + * shape's outline, a sticker's colours, a map's scope and framing; + * 2. **where it sits**: the position and size, folded unless the element is + * turned or faded (or is a sticker, which is turned as a matter of course), + * and for several at once their angle and opacity without the frame; + * 3. **how it looks**: type, colour, fill, frame; + * 4. **the effects**: folded, and open on an element that uses them; + * 5. **arrange**: lining up, stacking, grouping, the style clipboard. * - * ── What is not here any more ──────────────────────────────────────────── + * Every group mounts afresh for each selection (inspector/inspectorModel.ts), + * so a folded group opens for the element that uses it and closes again for + * the next one, and nothing is remembered behind the person's back. * - * Duplicate, lock and delete are in the panel head rather than in a section of - * their own: they act on the element rather than describing it, and a group - * called "Arrange" that held two of those next to the stacking buttons was the - * one group in the panel whose name did not say what was inside it. + * Duplicate, lock and delete are in the panel head rather than in a group of + * their own: they act on the element rather than describing it. */ +type Set = (patch: Partial) => void + export function StudioInspector({ spreadIndex, page, @@ -69,6 +61,7 @@ export function StudioInspector({ setPageNumbers, t, locale, + onOpenPanel, }: { spreadIndex: number page: BookPageSetup @@ -81,126 +74,35 @@ export function StudioInspector({ source: JourneySource t: (k: string) => string locale: string + /** Opens a panel of the rail on the other side: the Theme panel, or the Layers panel beside the selection. */ + onOpenPanel?: (section: RailSection) => void }) { - const [iconQuery, setIconQuery] = useState('') - /* - * A page of results, memoised because the inspector re-renders on every - * pointer move of a drag and the library is fourteen hundred names long. - */ - const iconChoices = useMemo(() => searchIcons(iconQuery).slice(0, ICON_CHOICES), [iconQuery]) const doc = useStudioStore(s => s.doc) const selection = useStudioStore(s => s.selection) const update = useStudioStore(s => s.updateElement) const commit = useStudioStore(s => s.commit) - const raise = useStudioStore(s => s.raise) - const duplicate = useStudioStore(s => s.duplicate) - const copy = useStudioStore(s => s.copy) - const removeElements = useStudioStore(s => s.removeElements) const spread = doc?.spreads[spreadIndex] const sel = spread?.elements.filter(e => selection.includes(e.id)) ?? [] + const el = sel[0] - if (!sel.length) { - /* - * Nothing selected, so this is where the *book's* own settings belong. - * The panel was showing one line of hint text and nothing else, and page - * numbers have nowhere else to go: they are not a property of any element, - * because the number a page carries depends on where its spread sits. - */ - const folios = page.pageNumbers + if (!el) { return ( - + onOpenPanel('theme'))} + t={t} + /> ) } - const el = sel[0] const one = sel.length === 1 // A property edit is one undo step on its own; only drags open a gesture. - const set = (patch: Partial) => commit(d => ({ + const set: Set = patch => commit(d => ({ ...d, spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : { ...sp, @@ -209,581 +111,130 @@ export function StudioInspector({ })), })) - const live = (patch: Partial) => update(spreadIndex, el.id, patch) - - /** - * Stretch the picture over the page it is on, or over both pages, bleed - * included. - * - * One commit rather than `set` followed by `raise`: the frame, the rotation - * put back to zero (a turned picture leaves bare corners), the cover crop and - * the move to the back are one press, and one press comes back with one - * undo. Which page is the one under the frame's centre; a cover has only - * the one. - */ - const fill = (scope: 'page' | 'spread') => commit(d => ({ - ...d, - spreads: d.spreads.map((sp, i) => { - if (i !== spreadIndex) return sp - const bleed = page.bleed - const onRight = sp.role === 'inner' && scope === 'page' - && el.frame.x + el.frame.w / 2 >= page.pageWidth - const frame = { - x: onRight ? page.pageWidth - bleed : -bleed, - y: -bleed, - w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed, - h: page.pageHeight + 2 * bleed, - } - const filled = { ...el, frame, rotation: 0, fit: 'cover' } as BookElement - return { ...sp, elements: [filled, ...sp.elements.filter(e => e.id !== el.id)] } - }), - })) + const live: Set = patch => update(spreadIndex, el.id, patch) return (