From a710bc53614393912131aca2d6fe89250b0657d8 Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 30 Dec 2025 16:21:02 -0700 Subject: [PATCH 01/65] BL-15642 Intro Page Settings --- .../prompts/bloom-test-CURRENTPAGE.prompt.md | 4 + .../skills/reviewable-thread-replies/SKILL.md | 93 ++ .../localization/en/BloomMediumPriority.xlf | 40 + .../BookAndPageSettingsDialog.tsx | 471 +++++++ .../BookSettingsConfigrPages.tsx | 775 ++++++++++++ .../FieldVisibilityGroup.tsx | 10 +- .../PageSettingsConfigrPages.tsx | 525 ++++++++ .../StyleAndFontTable.tsx | 0 .../appearanceThemeUtils.ts | 0 .../bookSettings/BookSettingsDialog.tsx | 1098 ----------------- .../bookEdit/css/origamiEditing.less | 36 +- .../js/CanvasElementContextControls.tsx | 2 +- src/BloomBrowserUI/bookEdit/js/origami.ts | 104 +- .../bookEdit/js/workspaceFrames.ts | 6 +- .../pageThumbnailList/PageThumbnail.tsx | 4 + .../pageThumbnailList/pageThumbnailList.tsx | 20 +- .../toolbox/canvas/customXmatterPage.tsx | 4 +- .../bookEdit/toolbox/toolbox.ts | 13 +- src/BloomBrowserUI/bookEdit/workspaceRoot.ts | 19 +- .../collection/CollectionSettingsDialog.tsx | 31 +- .../collectionsTab/BookButton.tsx | 2 +- .../CollectionsTabBookPane.tsx | 1 - src/BloomBrowserUI/package.json | 2 +- .../react_components/BookInfoIndicator.tsx | 4 +- .../react_components/bloomButton.tsx | 3 + .../color-picking/bloomPalette.ts | 26 + .../color-picking/bloomSketchPicker.tsx | 1 + .../color-picking/colorPicker.tsx | 229 +++- .../color-picking/colorPickerDialog.tsx | 486 +++++--- .../colorDisplayButton.uitest.ts | 124 ++ .../colorDisplayButtonTestHarness.tsx | 42 + .../component-tests/colorPicker.uitest.ts | 105 ++ .../colorPickerManualHarness.tsx | 41 + .../colorPickerTestHarness.tsx | 41 + .../component-tests/show-component.uitest.ts | 58 + .../color-picking/hexColorInput.tsx | 92 +- .../react_components/color-picking/show.sh | 13 + .../react_components/color-picking/test.sh | 15 + .../utils/ElementAttributeSnapshot.ts | 50 + src/BloomBrowserUI/yarn.lock | 8 +- src/BloomExe/Book/AppearanceSettings.cs | 2 + src/BloomExe/Book/HtmlDom.cs | 46 + src/BloomExe/Edit/EditingView.cs | 18 +- .../web/controllers/EditingViewApi.cs | 11 + .../Book/AppearanceSettingsTests.cs | 19 + .../appearance-theme-default.css | 3 + .../appearance-theme-rounded-border-ebook.css | 4 +- .../appearance-theme-zero-margin-ebook.css | 6 +- src/content/bookLayout/pageNumbers.less | 5 + yarn.lock | 4 + 50 files changed, 3349 insertions(+), 1367 deletions(-) create mode 100644 .github/prompts/bloom-test-CURRENTPAGE.prompt.md create mode 100644 .github/skills/reviewable-thread-replies/SKILL.md create mode 100644 src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx create mode 100644 src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx rename src/BloomBrowserUI/bookEdit/{bookSettings => bookAndPageSettings}/FieldVisibilityGroup.tsx (93%) create mode 100644 src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx rename src/BloomBrowserUI/bookEdit/{bookSettings => bookAndPageSettings}/StyleAndFontTable.tsx (100%) rename src/BloomBrowserUI/bookEdit/{bookSettings => bookAndPageSettings}/appearanceThemeUtils.ts (100%) delete mode 100644 src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerManualHarness.tsx create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerTestHarness.tsx create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/show-component.uitest.ts create mode 100644 src/BloomBrowserUI/react_components/color-picking/show.sh create mode 100644 src/BloomBrowserUI/react_components/color-picking/test.sh create mode 100644 src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts create mode 100644 yarn.lock diff --git a/.github/prompts/bloom-test-CURRENTPAGE.prompt.md b/.github/prompts/bloom-test-CURRENTPAGE.prompt.md new file mode 100644 index 000000000000..231bfff7cb13 --- /dev/null +++ b/.github/prompts/bloom-test-CURRENTPAGE.prompt.md @@ -0,0 +1,4 @@ +--- +description: use browser tools to test and debug +--- +The backend should already be running and serving a page at http://localhost:/bloom/CURRENTPAGE. is usually 8089. If i include a port number, use that, otherwise use 8089. You may use chrome-devtools-mcp, playwright-mcp, or other browser management tools. If you can't find any, use askQuestions tool to ask me to enable something for you to use. diff --git a/.github/skills/reviewable-thread-replies/SKILL.md b/.github/skills/reviewable-thread-replies/SKILL.md new file mode 100644 index 000000000000..466157a088c5 --- /dev/null +++ b/.github/skills/reviewable-thread-replies/SKILL.md @@ -0,0 +1,93 @@ +--- +name: reviewable-thread-replies +description: 'Reply to GitHub and Reviewable PR discussion threads one-by-one. Use whenever the user asks you to respond to review comments with accurate in-thread replies and verification.' +argument-hint: 'Repo/PR and target comments to reply to (for example: BloomBooks/BloomDesktop#7557 + specific discussion links/IDs)' +note: it's not clear that this skill is adequately developed, it's not clear that it works. +--- + +# Reviewable Thread Replies + +## What This Skill Does +Posts in-thread replies on both: +- GitHub PR review comments (`discussion_r...`) +- Reviewable-only discussion anchors quoted in review bodies + +## When To Use +- The user asks you to respond to one or more PR comments. +- Some comments are directly replyable on GitHub, while others only exist as Reviewable anchors. +- You need one response per thread, posted in the right place. + +## Inputs +- figure out the PR using the gh cli +- Target links or IDs (GitHub `discussion_r...` or Reviewable `#-...` anchors), or enough context to discover them. +- Reply text supplied by user, or instruction to compose replies from thread context. + +## Required Reply Format +- Every posted reply must begin with `[]`. +- Do not prepend workflow labels (for example `Will do, TODO`). + +## Procedure +1. Collect and normalize targets. +- Build a list of target threads with: `target`, `context`, `response`. +- If response text is not provided, compose a concise response from the thread context. +- Separate items into: + - GitHub direct thread comments (have comment IDs / `discussion_r...`). + - Reviewable-only threads (anchor IDs like `-Oko...`). + +2. Post direct GitHub thread replies first. +- Use GitHub PR review comment reply API/tool for each direct comment ID. +- Post exactly one response per thread. +- Verify the new reply IDs/URLs are returned. + +3. Open Reviewable, give the user time to authenticate. +- Navigate to the PR in Reviewable. +- If the user session is not active, use Reviewable sign-in flow and confirm identity before posting. + +4. Reply to Reviewable-only threads one by one. +- For each discussion anchor: + - Navigate to the anchor. + - Find the thread reply input for that discussion. + - Post response text with the required `[]` prefix. + - Avoid adding status macros or extra prefixes. +- Wait for each post to render before moving to the next thread. + +5. Verification pass. +- Re-check every target thread and confirm the expected response appears. +- Confirm no target remains unreplied due to navigation/context loss. +- Confirm no accidental text prefixes were added. + +## Decision Points +- If target has GitHub comment ID: use GitHub API/tool reply path. +- If target exists only in Reviewable anchor: use browser automation path. +- If Reviewable shows sign-in or disabled reply controls: authenticate first, then retry. +- Never click `resolve`, `done`, or `acknowledge` controls and never change discussion resolution state. +- If reply input transitions into a temporary composer panel: + - Submit without modifying response text semantics. + - Keep the required `[]` prefix and avoid workflow labels. +- If posted text does not match intended response: correct immediately before continuing. + +## Quality Criteria +- Exactly one intended response posted per target thread. +- Responses are correct for thread context and begin with `[]`. +- No unwanted prefixes like `Will do, TODO`. +- No unresolved posting errors left undocumented. +- Final status includes: posted targets and skipped/failed targets. + +## Guardrails +- Do not post broad summary comments when thread-level replies were requested. +- Do not resolve, acknowledge, dismiss, or otherwise change PR discussion status; leave resolution actions to humans. +- Do not rely on internal/private page APIs for mutation unless officially supported and permission-safe. +- Do not assume draft state implies publication; verify thread-visible posted output. +- Do not continue after repeated auth/permission failures without reporting the blocker. + +## Quick Command Hints +- List PR review comments: +```bash + gh api repos///pulls//comments --paginate +``` + +- List PR reviews (to inspect review-body quoted discussions): +```bash + gh api repos///pulls//reviews --paginate +``` + diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index 4d540aaf3a23..ee25020f4fe0 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -732,6 +732,46 @@ BookSettings.Title the heading of the dialog + + Book and Page Settings + ID: BookAndPageSettings.Title + the heading of the dialog + + + Book + ID: BookAndPageSettings.BookArea + Area label for tabs/pages that affect all pages in the current book. + + + Book settings apply to all of the pages of the current book. + ID: BookAndPageSettings.BookArea.Description + Description text shown for the Book area in the combined Book and Page Settings dialog. + + + Page + ID: BookAndPageSettings.PageArea + Area label for tabs/pages that affect only the current page. + + + Page settings apply to the current page. + ID: BookAndPageSettings.PageArea.Description + Description text shown for the Page area in the combined Book and Page Settings dialog. + + + Colors + ID: BookAndPageSettings.Colors + Label for the page-level Colors page within the combined Book and Page Settings dialog. + + + Page Settings + ID: PageSettings.Title + Title text for the standalone Page Settings dialog and the page settings button label above custom pages. + + + Open Page Settings... + ID: PageSettings.OpenTooltip + Tooltip shown when hovering over the Page Settings button above a custom page. + Max Image Size BookSettings.eBook.Image.MaxResolution diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx new file mode 100644 index 000000000000..4e23213dbee6 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -0,0 +1,471 @@ +import { css } from "@emotion/react"; +import { ConfigrArea, ConfigrPane, ConfigrValues } from "@sillsdev/config-r"; +import * as React from "react"; +import { kBloomBlue } from "../../bloomMaterialUITheme"; +import { + BloomDialog, + DialogBottomButtons, + DialogMiddle, + DialogTitle, +} from "../../react_components/BloomDialog/BloomDialog"; +import { useSetupBloomDialog } from "../../react_components/BloomDialog/BloomDialogPlumbing"; +import { + DialogCancelButton, + DialogOkButton, +} from "../../react_components/BloomDialog/commonDialogComponents"; +import { + post, + postJson, + useApiBoolean, + useApiObject, + useApiStringState, +} from "../../utils/bloomApi"; +import { useL10n } from "../../react_components/l10nHooks"; +import { getWorkspaceBundleExports } from "../js/workspaceFrames"; +import { ElementAttributeSnapshot } from "../../utils/ElementAttributeSnapshot"; +import { useGetFeatureStatus } from "../../react_components/featureStatus"; +import { + arePageSettingsEquivalent, + applyPageSettings, + getCurrentPageElement, + getCurrentPageSettings, + IPageSettings, + parsePageSettingsFromConfigrValue, + usePageSettingsAreaDefinition, +} from "./PageSettingsConfigrPages"; +import { useBookSettingsAreaDefinition } from "./BookSettingsConfigrPages"; + +let isOpenAlready = false; +const kBookSettingsDialogWidthPx = 900; +const kBookSettingsDialogHeightPx = 720; + +type IPageStyle = { label: string; value: string }; +type IPageStyles = Array; +type IAppearanceUIOptions = { + firstPossiblyLegacyCss?: string; + migratedTheme?: string; + themeNames: IPageStyles; +}; + +// Stuff we find in the appearance property of the object we get from the book/settings api. +// Not yet complete +export interface IAppearanceSettings { + cssThemeName: string; +} + +// Stuff we get from the book/settings api. +// Not yet complete +export interface IBookSettings { + appearance?: IAppearanceSettings; + firstPossiblyLegacyCss?: string; +} + +// Stuff we get from the book/settings/overrides api. +// The branding and xmatter objects contain the corresponding settings, +// using the same keys as appearance.json. Currently the values are all +// booleans. +interface IOverrideInformation { + branding: object; + xmatter: object; + brandingName: string; + xmatterName: string; +} + +export const BookAndPageSettingsDialog: React.FunctionComponent<{ + initiallySelectedPageKey?: string; +}> = (props) => { + const { closeDialog, propsForBloomDialog } = useSetupBloomDialog({ + initiallyOpen: true, + dialogFrameProvidedExternally: false, + }); + + const appearanceUIOptions: IAppearanceUIOptions = + useApiObject( + "book/settings/appearanceUIOptions", + { + themeNames: [], + }, + ); + + // If we pass a new default value to useApiObject on every render, it will query the host + // every time and then set the result, which triggers a new render, making an infinite loop. + const defaultOverrides = React.useMemo(() => { + return { + xmatter: {}, + branding: {}, + xmatterName: "", + brandingName: "", + }; + }, []); + + const overrideInformation: IOverrideInformation | undefined = + useApiObject( + "book/settings/overrides", + defaultOverrides, + ); + + const [pageSizeSupportsFullBleed] = useApiBoolean( + "book/settings/pageSizeSupportsFullBleed", + true, + ); + + const xmatterLockedBy = useL10n( + "Locked by {0} Front/Back matter", + "BookSettings.LockedByXMatter", + "", + overrideInformation?.xmatterName, + ); + + const brandingLockedBy = useL10n( + "Locked by {0} Branding", + "BookSettings.LockedByBranding", + "", + overrideInformation?.brandingName, + ); + + // This is a helper function to make it easier to pass the override information + function getAdditionalProps(subPath: string): { + path: string; + overrideValue: T; + overrideDescription?: string; + } { + // some properties will be overridden by branding and/or xmatter + const xmatterOverride: T | undefined = + overrideInformation?.xmatter?.[subPath]; + const brandingOverride = overrideInformation?.branding?.[subPath]; + const override = xmatterOverride ?? brandingOverride; + // nb: xmatterOverride can be boolean, hence the need to spell out !==undefined + let description = + xmatterOverride !== undefined ? xmatterLockedBy : undefined; + if (!description) { + // xmatter wins if both are present + description = + brandingOverride !== undefined ? brandingLockedBy : undefined; + } + // make a an object that can be spread as props in any of the Configr controls + return { + path: "appearance." + subPath, + overrideValue: override as T, + // if we're disabling all appearance controls (e.g. because we're in legacy), don't list a second reason for this overload + overrideDescription: appearanceDisabled ? "" : description, + }; + } + + const [settingsString] = useApiStringState( + "book/settings", + "{}", + () => propsForBloomDialog.open, + ); + + const [settings, setSettings] = React.useState( + undefined, + ); + + const [pageSettings, setPageSettings] = React.useState< + IPageSettings | undefined + >(undefined); + + const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< + ConfigrValues | undefined + >(undefined); + const latestSettingsRef = React.useRef( + undefined, + ); + const dialogRef = React.useRef(null); + + const setDialogVisibleWhileColorPickerOpen = React.useCallback( + (open: boolean) => { + const dialogRoot = dialogRef.current?.closest(".MuiDialog-root"); + if (!(dialogRoot instanceof HTMLElement)) { + return; + } + if (open) { + dialogRoot.style.visibility = "hidden"; + dialogRoot.style.pointerEvents = "none"; + } else { + dialogRoot.style.visibility = ""; + dialogRoot.style.pointerEvents = ""; + } + }, + [], + ); + + const removePageSettingsFromConfigrSettings = ( + settingsValue: ConfigrValues, + ): IBookSettings => { + const settingsWithoutPage = { + ...settingsValue, + } as Record; + delete settingsWithoutPage["page"]; + return settingsWithoutPage as IBookSettings; + }; + + const configrInitialValues: ConfigrValues | undefined = + React.useMemo(() => { + if (!settings || !pageSettings) { + return undefined; + } + + return { + ...settings, + page: pageSettings.page, + } as unknown as ConfigrValues; + }, [settings, pageSettings]); + + const [appearanceDisabled, setAppearanceDisabled] = React.useState(false); + + // We use state here to allow the dialog UI to update without permanently changing the settings + // and getting notified of those changes. The changes are persisted when the user clicks OK. + const [theme, setTheme] = React.useState(""); + const [firstPossiblyLegacyCss, setFirstPossiblyLegacyCss] = + React.useState(""); + const [migratedTheme, setMigratedTheme] = React.useState(""); + + const initialPageAttributeSnapshot = React.useRef< + ElementAttributeSnapshot | undefined + >(undefined); + + React.useEffect(() => { + if (settingsString === "{}") { + return; // leave settings as undefined + } + if (typeof settingsString === "string") { + setSettings(JSON.parse(settingsString)); + } else { + setSettings(settingsString); + } + }, [settingsString]); + + React.useEffect(() => { + setPageSettings(getCurrentPageSettings()); + initialPageAttributeSnapshot.current = + ElementAttributeSnapshot.fromElement(getCurrentPageElement()); + }, []); + + React.useEffect(() => { + return () => { + setDialogVisibleWhileColorPickerOpen(false); + }; + }, [setDialogVisibleWhileColorPickerOpen]); + + React.useEffect(() => { + setFirstPossiblyLegacyCss( + appearanceUIOptions?.firstPossiblyLegacyCss ?? "", + ); + setMigratedTheme(appearanceUIOptions?.migratedTheme ?? ""); + }, [appearanceUIOptions]); + + const bookSettingsTitle = useL10n( + "Book and Page Settings", + "BookAndPageSettings.Title", + ); + + React.useEffect(() => { + if (settings?.appearance) { + const liveAppearance = + (settingsToReturnLater?.["appearance"] as + | IAppearanceSettings + | undefined) ?? settings.appearance; + // when we're in legacy, we're just going to disable all the appearance controls + setAppearanceDisabled( + liveAppearance?.cssThemeName === "legacy-5-6", + ); + setTheme(liveAppearance?.cssThemeName ?? ""); + } + }, [settings, settingsToReturnLater]); + + const deleteCustomBookStyles = () => { + post( + `book/settings/deleteCustomBookStyles?file=${firstPossiblyLegacyCss}`, + ); + setFirstPossiblyLegacyCss(""); + setMigratedTheme(""); + }; + + const tierAllowsFullPageCoverImage = + useGetFeatureStatus("fullPageCoverImage")?.enabled; + + const tierAllowsFullBleed = useGetFeatureStatus("PrintshopReady")?.enabled; + + const closeDialogAndClearOpenFlag = React.useCallback(() => { + latestSettingsRef.current = undefined; + isOpenAlready = false; + closeDialog(); + }, [closeDialog]); + + const cancelAndCloseDialog = React.useCallback(() => { + if (initialPageAttributeSnapshot.current) { + initialPageAttributeSnapshot.current.restoreToElement( + getCurrentPageElement(), + ); + } + closeDialogAndClearOpenFlag(); + }, [closeDialogAndClearOpenFlag]); + + function saveSettingsAndCloseDialog() { + const latestSettings = + latestSettingsRef.current ?? settingsToReturnLater; + if (latestSettings) { + applyPageSettings( + parsePageSettingsFromConfigrValue(latestSettings), + ); + + const settingsToPost = + removePageSettingsFromConfigrSettings(latestSettings); + // If nothing changed, we don't get any...and don't need to make this call. + postJson("book/settings", settingsToPost); + } + + closeDialogAndClearOpenFlag(); + // todo: how do we make the pageThumbnailList reload? It's in a different browser, so + // we can't use a global. It listens to websocket, but we currently can only listen, + // we cannot send. + } + + const bookSettingsArea = useBookSettingsAreaDefinition({ + appearanceDisabled, + tierAllowsFullPageCoverImage, + tierAllowsFullBleed, + pageSizeSupportsFullBleed, + settings, + settingsToReturnLater, + getAdditionalProps, + firstPossiblyLegacyCss, + theme, + migratedTheme, + deleteCustomBookStyles, + saveSettingsAndCloseDialog, + onColorPickerVisibilityChanged: setDialogVisibleWhileColorPickerOpen, + themeNames: appearanceUIOptions.themeNames, + }); + + const pageSettingsArea = usePageSettingsAreaDefinition({ + onColorPickerVisibilityChanged: setDialogVisibleWhileColorPickerOpen, + }); + + return ( + cancelAndCloseDialog()} + onCancel={() => cancelAndCloseDialog()} + draggable={false} + maxWidth={false} + > + + + {configrInitialValues && ( + { + const parsedPageSettings = + parsePageSettingsFromConfigrValue(s); + const isInitialConfigrEcho = + !settingsToReturnLater && + !!pageSettings && + arePageSettingsEquivalent( + parsedPageSettings, + pageSettings, + ); + + // Config-r may call onChange while rendering, so defer state updates. + latestSettingsRef.current = s; + window.setTimeout(() => { + setSettingsToReturnLater(s); + }, 0); + + if (isInitialConfigrEcho) { + return; + } + + applyPageSettings(parsedPageSettings); + }} + initiallySelectedTopLevelPageKey={ + props.initiallySelectedPageKey + } + > + + {bookSettingsArea.pages} + + + {pageSettingsArea.pages} + + + )} + + + + + + + ); +}; + +export function showBookSettingsDialog(initiallySelectedPageKey?: string) { + // once Bloom's tab bar is also in react, it won't be possible + // to open another copy of this without closing it first, but + // for now, we need to prevent that. + if (!isOpenAlready) { + isOpenAlready = true; + try { + getWorkspaceBundleExports().ShowEditViewDialog( + , + ); + } catch (error) { + isOpenAlready = false; + throw error; + } + } +} diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx new file mode 100644 index 000000000000..fe6c494c4d93 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx @@ -0,0 +1,775 @@ +import { css } from "@emotion/react"; +import { Slider, Typography } from "@mui/material"; +import { ThemeProvider } from "@mui/material/styles"; +import { + ConfigrBoolean, + ConfigrCustomObjectInput, + ConfigrCustomStringInput, + ConfigrGroup, + ConfigrPage, + ConfigrSelect, + ConfigrStatic, +} from "@sillsdev/config-r"; +import { default as TrashIcon } from "@mui/icons-material/Delete"; +import * as React from "react"; +import { kBloomBlue, lightTheme } from "../../bloomMaterialUITheme"; +import { NoteBox, WarningBox } from "../../react_components/boxes"; +import { Div, P } from "../../react_components/l10nComponents"; +import { useL10n } from "../../react_components/l10nHooks"; +import { PWithLink } from "../../react_components/pWithLink"; +import { BloomSubscriptionIndicatorIconAndText } from "../../react_components/requiresSubscription"; +import { BloomPalette } from "../../react_components/color-picking/bloomPalette"; +import { + ColorDisplayButton, + DialogResult, +} from "../../react_components/color-picking/colorPickerDialog"; +import { FieldVisibilityGroup } from "./FieldVisibilityGroup"; +import { StyleAndFontTable } from "./StyleAndFontTable"; + +// Should stay in sync with AppearanceSettings.PageNumberPosition +enum PageNumberPosition { + Automatic = "automatic", + Left = "left", + Center = "center", + Right = "right", + Hidden = "hidden", +} + +type Resolution = { + maxWidth: number; + maxHeight: number; +}; + +type BookSettingsAreaProps = { + appearanceDisabled: boolean; + tierAllowsFullPageCoverImage?: boolean; + tierAllowsFullBleed?: boolean; + pageSizeSupportsFullBleed: boolean; + settings: object | undefined; + settingsToReturnLater: object | undefined; + getAdditionalProps: (subPath: string) => { + path: string; + overrideValue: T; + overrideDescription?: string; + }; + firstPossiblyLegacyCss: string; + theme: string; + migratedTheme: string; + deleteCustomBookStyles: () => void; + saveSettingsAndCloseDialog: () => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; + themeNames: Array<{ label: string; value: string }>; +}; + +export type IConfigrAreaDefinition = { + label: string; + pageKey: string; + content: string; + pages: React.ReactElement[]; +}; + +export const useBookSettingsAreaDefinition = ( + props: BookSettingsAreaProps, +): IConfigrAreaDefinition => { + const bookAreaLabel = useL10n("Book", "BookAndPageSettings.BookArea"); + const bookAreaDescription = useL10n( + "Book settings apply to all of the pages of the current book.", + "BookAndPageSettings.BookArea.Description", + ); + + const coverLabel = useL10n("Cover", "BookSettings.CoverGroupLabel"); + const contentPagesLabel = useL10n( + "Content Pages", + "BookSettings.ContentPagesGroupLabel", + ); + const printPublishingLabel = useL10n( + "Print Publishing", + "BookSettings.PrintPublishingGroupLabel", + ); + const languagesToShowNormalSubgroupLabel = useL10n( + "Languages to show in normal text boxes", + "BookSettings.NormalTextBoxLangsLabel", + "", + ); + const themeLabel = useL10n("Page Theme", "BookSettings.PageThemeLabel", ""); + const themeDescription = useL10n( + "", // will be translated or the English will come from the xliff + "BookSettings.Theme.Description", + ); + + const coverBackgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + + const whatToShowOnCoverLabel = useL10n( + "Front Cover", + "BookSettings.WhatToShowOnCover", + ); + + const showLanguageNameLabel = useL10n( + "Show Language Name", + "BookSettings.ShowLanguageName", + ); + const showTopicLabel = useL10n("Show Topic", "BookSettings.ShowTopic"); + const showCreditsLabel = useL10n( + "Show Credits", + "BookSettings.ShowCredits", + ); + const pageNumbersLabel = useL10n( + "Page Numbers", + "BookSettings.PageNumbers", + ); + const pageNumberLocationNote = useL10n( + "Note: some Page Themes may not know how to change the location of the Page Number.", + "BookSettings.PageNumberLocationNote", + ); + const pageNumberPositionAutomaticLabel = useL10n( + "(Automatic)", + "BookSettings.PageNumbers.Automatic", + ); + const pageNumberPositionLeftLabel = useL10n( + "Left", + "BookSettings.PageNumbers.Left", + ); + const pageNumberPositionCenterLabel = useL10n( + "Center", + "BookSettings.PageNumbers.Center", + ); + const pageNumberPositionRightLabel = useL10n( + "Right", + "BookSettings.PageNumbers.Right", + ); + const pageNumberPositionHiddenLabel = useL10n( + "Hidden", + "BookSettings.PageNumbers.Hidden", + ); + + const resolutionLabel = useL10n("Resolution", "BookSettings.Resolution"); + const bloomPubLabel = useL10n("eBooks", "PublishTab.bloomPUBButton"); + + const advancedLayoutLabel = useL10n( + "Advanced Layout", + "BookSettings.AdvancedLayoutLabel", + ); + const textPaddingLabel = useL10n( + "Text Padding", + "BookSettings.TopLevelTextPaddingLabel", + ); + const textPaddingDescription = useL10n( + "Smart spacing around text boxes. Works well for simple pages, but may not suit custom layouts.", + "BookSettings.TopLevelTextPadding.Description", + ); + const textPaddingDefaultLabel = useL10n( + "Default (set by Theme)", + "BookSettings.TopLevelTextPadding.DefaultLabel", + ); + const textPadding1emLabel = useL10n( + "1 em (font size)", + "BookSettings.TopLevelTextPadding.1emLabel", + ); + + const gutterLabel = useL10n("Page Gutter", "BookSettings.Gutter.Label"); + const gutterDescription = useL10n( + "Extra space between pages near the book spine. Increase this for books with many pages to ensure text isn't lost in the binding. This gap is applied to each side of the spine.", + "BookSettings.Gutter.Description", + ); + const gutterDefaultLabel = useL10n( + "Default (set by Theme)", + "BookSettings.Gutter.DefaultLabel", + ); + + const coverIsImageLabel = useL10n( + "Fill the front cover with a single image", + "BookSettings.CoverIsImage", + ); + const coverIsImageDescription = useL10n( + "Replace the front cover content with a single full-bleed image. See [Full Page Cover Images](https://docs.bloomlibrary.org/full-page-cover-images) for information on sizing your image to fit.", + "BookSettings.CoverIsImage.Description.V2", + ); + + const fullBleedLabel = useL10n( + "Use full bleed page layout", + "BookSettings.FullBleed", + ); + const fullBleedDescription = useL10n( + "Enable full bleed layout for printing. This turns on the [Print Bleed](https://en.wikipedia.org/wiki/Bleed_%28printing%29) indicators on paper layouts. See [Full Bleed Layout](https://docs.bloomlibrary.org/full-bleed) for more information.", + "BookSettings.FullBleed.Description", + ); + + const coverColorPickerControl = React.useCallback( + (coverColorProps: { + value: string; + disabled: boolean; + onChange: (value: string) => void; + }) => { + return ( + + ); + }, + [props.onColorPickerVisibilityChanged], + ); + + return { + label: bookAreaLabel, + pageKey: "bookArea", + content: bookAreaDescription, + pages: [ + + {props.appearanceDisabled && ( + + +
+ The selected page theme does not support the + following settings. +
+
+
+ )} + +
+ ( + `coverIsImage`, + )} + disabled={ + props.appearanceDisabled || + !props.tierAllowsFullPageCoverImage + } + /> +
+ +
+
+ + + ( + `cover-languageName-show`, + )} + /> + ( + `cover-topic-show`, + )} + /> + ( + `cover-creditsRow-show`, + )} + /> +
+ + ( + `cover-background-color`, + )} + /> + +
, + + { + // This group of four possible messages...sometimes none of them shows, so there are five options... + // is very similar to the one in BookInfoIndicator.tsx. If you change one, you may need to change the other. + // In particular, the logic for which to show and the text of the messages should be kept in sync. + // I'm not seeing a clean way to reuse the logic. Some sort of higher-order component might work, + // but I don't think the logic is complex enough to be worth it, when only used in two places. + } + {props.firstPossiblyLegacyCss.length > 0 && + props.theme === "legacy-5-6" && ( + + + + + + )} + {props.firstPossiblyLegacyCss === "customBookStyles.css" && + props.theme !== "legacy-5-6" && ( + + +
+ {props.migratedTheme ? ( + + ) : ( + + )} +
+ props.deleteCustomBookStyles() + } + > + +
+ Delete{" "} + {props.firstPossiblyLegacyCss} +
+
+
+
+
+ )} + {props.firstPossiblyLegacyCss.length > 0 && + props.firstPossiblyLegacyCss !== "customBookStyles.css" && + props.theme !== "legacy-5-6" && ( + + + + + + )} + + {/* Wrapping these two in a div prevents Config-R from sticking a divider between them */} +
+ { + return { + label: x.label, + value: x.value, + }; + })} + description={themeDescription} + /> + {props.appearanceDisabled && ( + +
+ The selected page theme does not support the + following settings. +
+
+ )} +
+ ( + `pageNumber-position`, + )} + options={[ + { + label: pageNumberPositionAutomaticLabel, + value: PageNumberPosition.Automatic, + }, + { + label: pageNumberPositionLeftLabel, + value: PageNumberPosition.Left, + }, + { + label: pageNumberPositionCenterLabel, + value: PageNumberPosition.Center, + }, + { + label: pageNumberPositionRightLabel, + value: PageNumberPosition.Right, + }, + { + label: "--", + value: "--", + }, + { + label: pageNumberPositionHiddenLabel, + value: PageNumberPosition.Hidden, + }, + ]} + description={pageNumberLocationNote} + /> +
+ + + + + ( + `topLevel-text-padding`, + )} + /> + (`page-gutter`)} + /> + +
, + + +
+ (`fullBleed`)} + disabled={ + !props.tierAllowsFullBleed || + !props.pageSizeSupportsFullBleed + } + /> +
+ +
+
+
+
, + + {/* note that this is used for bloomPUB and ePUB, but we don't have separate settings so we're putting them in bloomPUB and leaving it to c# code to use it for ePUB as well. */} + + + + , + + + + +
+

+ When you publish a book to the web or as an + ebook, Bloom will flag any problematic + fonts. For example, we cannot legally host + most Microsoft fonts on BloomLibrary.org. +

+

+ The following table shows where fonts have + been used. +

+
+
+ +
+
+
, + ], + }; +}; + +const BloomResolutionSlider: React.FunctionComponent< + React.PropsWithChildren<{ + path: string; + label: string; + }> +> = (props) => { + return ( +
+ + control={BloomResolutionSliderInner} + {...props} + > +
+ Bloom reduces images to a maximum size to make books easier to + view over poor internet connections and take up less space on + phones. +
+
+ ); +}; + +const BloomResolutionSliderInner: React.FunctionComponent<{ + value: Resolution; + onChange: (value: Resolution) => void; +}> = (props) => { + const sizes = [ + { l: "Small", w: 600, h: 600 }, + { l: "HD", w: 1280, h: 720 }, + { l: "Full HD", w: 1920, h: 1080 }, + { l: "4K", w: 3840, h: 2160 }, + ]; + let currentIndex = sizes.findIndex((x) => x.w === props.value.maxWidth); + if (currentIndex === -1) { + currentIndex = 1; // See BL-12803. + } + const current = sizes[currentIndex]; + const currentLabel = useL10n( + current.l, + `BookSettings.eBook.Image.MaxResolution.${current.l}`, + ); + + return ( + +
+ {`${currentLabel}`} + { + return `${current.w}x${current.h}`; + }} + onChange={(e, value) => { + props.onChange({ + maxWidth: sizes[value as number].w, + maxHeight: sizes[value as number].h, + }); + }} + valueLabelDisplay="auto" + > +
+
+ ); +}; + +const CoverColorPickerForConfigr: React.FunctionComponent<{ + value: string; + disabled: boolean; + onChange: (value: string) => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const coverBackgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + /> + ); +}; + +export const MessageUsingLegacyThemeWithIncompatibleCss: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( + + The {0} stylesheet of this book is incompatible with modern themes. + Bloom is using it because the book is using the Legacy-5-6 theme. + Click [here] for more information. + + ); +}; + +export const MessageUsingMigratedThemeInsteadOfIncompatibleCss: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( +
+ Bloom found a known version of {props.fileName} in this book and + replaced it with a modern theme. You can delete it unless you still + need to publish the book from an earlier version of Bloom. +
+ ); +}; + +export const MessageIgnoringIncompatibleCssCanDelete: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( + + The + {props.fileName} stylesheet of this book is incompatible with modern + themes. Bloom is currently ignoring it. If you don't need those + customizations any more, you can delete your + {props.fileName}. Click [here] for more information. + + ); +}; + +export const MessageIgnoringIncompatibleCss: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( + + The {props.fileName} stylesheet of this book is incompatible with + modern themes. Bloom is currently ignoring it. Click [here] for more + information. + + ); +}; diff --git a/src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx similarity index 93% rename from src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx rename to src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx index de7d6d65455b..fb6578287dd8 100644 --- a/src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx @@ -23,7 +23,7 @@ export const FieldVisibilityGroup: React.FunctionComponent<{ labelFrame: string; labelFrameL10nKey: string; settings: object | undefined; - settingsToReturnLater: string | object | undefined; + settingsToReturnLater: object | undefined; disabled: boolean; L1MustBeTurnedOn?: boolean; @@ -88,13 +88,7 @@ export const FieldVisibilityGroup: React.FunctionComponent<{ const [showL1, showL2, showL3, numberShowing] = useMemo(() => { let appearance = props.settings?.["appearance"]; if (props.settingsToReturnLater) { - // although we originally declared it a string, Config-R may return a JSON string or an object - if (typeof props.settingsToReturnLater === "string") { - const parsedSettings = JSON.parse(props.settingsToReturnLater); - appearance = parsedSettings["appearance"]; - } else { - appearance = props.settingsToReturnLater["appearance"]; - } + appearance = props.settingsToReturnLater["appearance"]; } if (!appearance) { // This is a bit arbitrary. It should only apply during early renders. diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx new file mode 100644 index 000000000000..0e4304c28df9 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx @@ -0,0 +1,525 @@ +import * as React from "react"; +import { + ConfigrCustomStringInput, + ConfigrGroup, + ConfigrPage, +} from "@sillsdev/config-r"; +import tinycolor from "tinycolor2"; +import { + ColorDisplayButton, + DialogResult, +} from "../../react_components/color-picking/colorPickerDialog"; +import { BloomPalette } from "../../react_components/color-picking/bloomPalette"; +import { useL10n } from "../../react_components/l10nHooks"; +import { getPageIframeBody } from "../../utils/shared"; + +export type IPageSettings = { + page: { + backgroundColor: string; + pageNumberColor: string; + pageNumberOutlineColor: string; + pageNumberBackgroundColor: string; + }; +}; + +export const getCurrentPageElement = (): HTMLElement => { + const page = getPageIframeBody()?.querySelector( + ".bloom-page", + ) as HTMLElement | null; + if (!page) { + throw new Error( + "PageSettingsConfigrPages could not find .bloom-page in the page iframe", + ); + } + return page; +}; + +const kTransparentCssValue = "transparent"; + +const normalizeToHexOrEmpty = (color: string): string => { + const trimmed = color.trim(); + if (!trimmed) { + return ""; + } + + const parsed = tinycolor(trimmed); + if (!parsed.isValid()) { + return trimmed; + } + + // Treat fully transparent as "not set". + if (parsed.getAlpha() === 0) { + return ""; + } + + if (parsed.getAlpha() < 1) { + return parsed.toHex8String().toUpperCase(); + } + + return parsed.toHexString().toUpperCase(); +}; + +const normalizeToHexOrTransparentOrEmpty = (color: string): string => { + const trimmed = color.trim(); + if (!trimmed) { + return ""; + } + + const parsed = tinycolor(trimmed); + if (!parsed.isValid()) { + return trimmed; + } + + if (parsed.getAlpha() === 0) { + return kTransparentCssValue; + } + + if (parsed.getAlpha() < 1) { + return parsed.toHex8String().toUpperCase(); + } + + return parsed.toHexString().toUpperCase(); +}; + +const getComputedStyleForPage = (page: HTMLElement): CSSStyleDeclaration => { + const view = page.ownerDocument.defaultView; + if (view) { + return view.getComputedStyle(page); + } + return getComputedStyle(page); +}; + +const getCurrentPageBackgroundColor = (): string => { + const page = getCurrentPageElement(); + const computedPage = getComputedStyleForPage(page); + + const inlineMarginBox = normalizeToHexOrEmpty( + page.style.getPropertyValue("--marginBox-background-color"), + ); + if (inlineMarginBox) return inlineMarginBox; + + const inline = normalizeToHexOrEmpty( + page.style.getPropertyValue("--page-background-color"), + ); + if (inline) return inline; + + const computedMarginBoxVariable = normalizeToHexOrEmpty( + computedPage.getPropertyValue("--marginBox-background-color"), + ); + if (computedMarginBoxVariable) return computedMarginBoxVariable; + + const computedVariable = normalizeToHexOrEmpty( + computedPage.getPropertyValue("--page-background-color"), + ); + if (computedVariable) return computedVariable; + + const marginBox = page.querySelector(".marginBox") as HTMLElement | null; + if (marginBox) { + const computedMarginBoxBackground = normalizeToHexOrEmpty( + getComputedStyleForPage(marginBox).backgroundColor, + ); + if (computedMarginBoxBackground) return computedMarginBoxBackground; + } + + const computedBackground = normalizeToHexOrEmpty( + computedPage.backgroundColor, + ); + return computedBackground || "#FFFFFF"; +}; + +const setOrRemoveCustomProperty = ( + style: CSSStyleDeclaration, + propertyName: string, + value: string, +): void => { + const normalized = normalizeToHexOrEmpty(value); + if (normalized) { + style.setProperty(propertyName, normalized); + } else { + style.removeProperty(propertyName); + } +}; + +const setOrRemoveCustomPropertyAllowTransparent = ( + style: CSSStyleDeclaration, + propertyName: string, + value: string, +): void => { + const normalized = normalizeToHexOrTransparentOrEmpty(value); + if (normalized) { + style.setProperty(propertyName, normalized); + } else { + style.removeProperty(propertyName); + } +}; + +const setCurrentPageBackgroundColor = (color: string): void => { + const page = getCurrentPageElement(); + setOrRemoveCustomProperty(page.style, "--page-background-color", color); + setOrRemoveCustomProperty( + page.style, + "--marginBox-background-color", + color, + ); +}; + +const getPageNumberColor = (): string => { + const page = getCurrentPageElement(); + + const inline = normalizeToHexOrEmpty( + page.style.getPropertyValue("--pageNumber-color"), + ); + if (inline) return inline; + + const computed = normalizeToHexOrEmpty( + getComputedStyleForPage(page).getPropertyValue("--pageNumber-color"), + ); + return computed || "#000000"; +}; + +const setPageNumberColor = (color: string): void => { + const page = getCurrentPageElement(); + setOrRemoveCustomProperty(page.style, "--pageNumber-color", color); +}; + +const getPageNumberOutlineColor = (): string => { + const page = getCurrentPageElement(); + + const inline = normalizeToHexOrTransparentOrEmpty( + page.style.getPropertyValue("--pageNumber-outline-color"), + ); + if (inline) return inline; + + const computed = normalizeToHexOrTransparentOrEmpty( + getComputedStyleForPage(page).getPropertyValue( + "--pageNumber-outline-color", + ), + ); + return computed || "#FFFFFF"; +}; + +const setPageNumberOutlineColor = (color: string): void => { + const page = getCurrentPageElement(); + setOrRemoveCustomPropertyAllowTransparent( + page.style, + "--pageNumber-outline-color", + color, + ); +}; + +const getPageNumberBackgroundColor = (): string => { + const page = getCurrentPageElement(); + + const inline = normalizeToHexOrTransparentOrEmpty( + page.style.getPropertyValue("--pageNumber-background-color"), + ); + if (inline) return inline; + + return kTransparentCssValue; +}; + +const setPageNumberBackgroundColor = (color: string): void => { + const page = getCurrentPageElement(); + setOrRemoveCustomPropertyAllowTransparent( + page.style, + "--pageNumber-background-color", + color, + ); +}; + +export const getCurrentPageSettings = (): IPageSettings => { + return { + page: { + backgroundColor: getCurrentPageBackgroundColor(), + pageNumberColor: getPageNumberColor(), + pageNumberOutlineColor: getPageNumberOutlineColor(), + pageNumberBackgroundColor: getPageNumberBackgroundColor(), + }, + }; +}; + +export const applyPageSettings = (settings: IPageSettings): void => { + setCurrentPageBackgroundColor(settings.page.backgroundColor); + setPageNumberColor(settings.page.pageNumberColor); + setPageNumberOutlineColor(settings.page.pageNumberOutlineColor); + setPageNumberBackgroundColor(settings.page.pageNumberBackgroundColor); +}; + +export const parsePageSettingsFromConfigrValue = ( + value: unknown, +): IPageSettings => { + if (typeof value !== "object" || !value) { + throw new Error("Page settings are not an object"); + } + const parsedRecord = value as Record; + const pageValues = parsedRecord["page"]; + + if (typeof pageValues !== "object" || !pageValues) { + throw new Error("Page settings are missing the page object"); + } + + const pageRecord = pageValues as Record; + + const backgroundColor = pageRecord["backgroundColor"]; + const pageNumberColor = pageRecord["pageNumberColor"]; + const pageNumberOutlineColor = pageRecord["pageNumberOutlineColor"]; + const pageNumberBackgroundColor = pageRecord["pageNumberBackgroundColor"]; + + if ( + typeof backgroundColor !== "string" || + typeof pageNumberColor !== "string" || + typeof pageNumberOutlineColor !== "string" || + typeof pageNumberBackgroundColor !== "string" + ) { + throw new Error("Page settings are missing one or more color values"); + } + + return { + page: { + backgroundColor, + pageNumberColor, + pageNumberOutlineColor, + pageNumberBackgroundColor, + }, + }; +}; + +export const arePageSettingsEquivalent = ( + first: IPageSettings, + second: IPageSettings, +): boolean => { + return ( + normalizeToHexOrEmpty(first.page.backgroundColor) === + normalizeToHexOrEmpty(second.page.backgroundColor) && + normalizeToHexOrEmpty(first.page.pageNumberColor) === + normalizeToHexOrEmpty(second.page.pageNumberColor) && + normalizeToHexOrTransparentOrEmpty( + first.page.pageNumberOutlineColor, + ) === + normalizeToHexOrTransparentOrEmpty( + second.page.pageNumberOutlineColor, + ) && + normalizeToHexOrTransparentOrEmpty( + first.page.pageNumberBackgroundColor, + ) === + normalizeToHexOrTransparentOrEmpty( + second.page.pageNumberBackgroundColor, + ) + ); +}; + +type IConfigrColorPickerControlProps = { + value: string; + disabled?: boolean; + onChange: (value: string) => void; +}; + +const ConfigrColorPickerControl: React.FunctionComponent< + IConfigrColorPickerControlProps & { + localizedTitle: string; + transparency: boolean; + palette: BloomPalette; + emptyValueDisplayColor?: string; + onColorPickerVisibilityChanged?: (open: boolean) => void; + } +> = (props) => { + const initialColor = props.value || props.emptyValueDisplayColor; + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + onChange={(newColor) => props.onChange(newColor)} + /> + ); +}; + +const PageSettingsConfigrColorInput: React.FunctionComponent<{ + label: string; + path: string; + description?: string; + localizedTitle: string; + transparency: boolean; + palette: BloomPalette; + emptyValueDisplayColor?: string; + disabled?: boolean; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const colorControl = React.useCallback( + (pickerProps: IConfigrColorPickerControlProps) => ( + + ), + [ + props.emptyValueDisplayColor, + props.localizedTitle, + props.onColorPickerVisibilityChanged, + props.palette, + props.transparency, + ], + ); + + return ( + + ); +}; + +const PageConfigrInputs: React.FunctionComponent<{ + disabled?: boolean; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const backgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + + return ( + + ); +}; + +/* + * BL-15642: hide the page number color group for now. + * We could add this back in the future, perhaps as a book settings feature + * instead of a page settings feature. + */ +// const PageNumberConfigrInputs: React.FunctionComponent<{ +// disabled?: boolean; +// onColorPickerVisibilityChanged?: (open: boolean) => void; +// }> = (props) => { +// const colorLabel = useL10n("Color", "Common.Color"); +// const outlineColorLabel = useL10n( +// "Outline Color", +// "PageSettings.OutlineColor", +// ); +// const outlineColorDescription = useL10n( +// "Use an outline color when the page number needs more contrast against the page.", +// "PageSettings.PageNumberOutlineColor.Description", +// ); +// const backgroundColorLabel = useL10n( +// "Background Color", +// "Common.BackgroundColor", +// ); +// const backgroundColorDescription = useL10n( +// "Use a page number background color when the theme puts the number inside a shape, for example a circle, and you want to specify the color of that shape.", +// "PageSettings.PageNumberBackgroundColor.Description", +// ); +// +// return ( +// <> +// +// +// +// +// ); +// }; + +export type IPageSettingsAreaDefinition = { + label: string; + pageKey: string; + content: string; + pages: React.ReactElement[]; +}; + +export const usePageSettingsAreaDefinition = (props: { + onColorPickerVisibilityChanged?: (open: boolean) => void; +}): IPageSettingsAreaDefinition => { + const pageAreaLabel = useL10n("Page", "BookAndPageSettings.PageArea"); + const colorsPageLabel = useL10n("Colors", "BookAndPageSettings.Colors"); + const pageAreaDescription = useL10n( + "Page settings apply to the current page.", + "BookAndPageSettings.PageArea.Description", + ); + + return { + label: pageAreaLabel, + pageKey: "pageArea", + content: pageAreaDescription, + pages: [ + + + + + {/* + BL-15642: hide the page number color group for now. + We could add this back in the future, perhaps as a book + settings feature instead of a page settings feature. + */} + , + ], + }; +}; diff --git a/src/BloomBrowserUI/bookEdit/bookSettings/StyleAndFontTable.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/StyleAndFontTable.tsx similarity index 100% rename from src/BloomBrowserUI/bookEdit/bookSettings/StyleAndFontTable.tsx rename to src/BloomBrowserUI/bookEdit/bookAndPageSettings/StyleAndFontTable.tsx diff --git a/src/BloomBrowserUI/bookEdit/bookSettings/appearanceThemeUtils.ts b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/appearanceThemeUtils.ts similarity index 100% rename from src/BloomBrowserUI/bookEdit/bookSettings/appearanceThemeUtils.ts rename to src/BloomBrowserUI/bookEdit/bookAndPageSettings/appearanceThemeUtils.ts diff --git a/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx deleted file mode 100644 index 270213542984..000000000000 --- a/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx +++ /dev/null @@ -1,1098 +0,0 @@ -import { css } from "@emotion/react"; -import { Slider, Typography } from "@mui/material"; -import { - ConfigrPane, - ConfigrPage, - ConfigrGroup, - ConfigrStatic, - ConfigrCustomStringInput, - ConfigrCustomObjectInput, - ConfigrBoolean, - ConfigrSelect, -} from "@sillsdev/config-r"; -import * as React from "react"; -import { kBloomBlue, lightTheme } from "../../bloomMaterialUITheme"; -import { ThemeProvider } from "@mui/material/styles"; -import { - BloomDialog, - DialogMiddle, - DialogBottomButtons, - DialogTitle, -} from "../../react_components/BloomDialog/BloomDialog"; -import { useSetupBloomDialog } from "../../react_components/BloomDialog/BloomDialogPlumbing"; -import { - DialogCancelButton, - DialogOkButton, -} from "../../react_components/BloomDialog/commonDialogComponents"; -import { BloomPalette } from "../../react_components/color-picking/bloomPalette"; -import { - ColorDisplayButton, - DialogResult, -} from "../../react_components/color-picking/colorPickerDialog"; -import { - post, - postJson, - useApiBoolean, - useApiObject, - useApiStringState, -} from "../../utils/bloomApi"; -import { ShowEditViewDialog } from "../workspaceRoot"; -import { useL10n } from "../../react_components/l10nHooks"; -import { Div, P } from "../../react_components/l10nComponents"; -import { NoteBox, WarningBox } from "../../react_components/boxes"; -import { default as TrashIcon } from "@mui/icons-material/Delete"; -import { PWithLink } from "../../react_components/pWithLink"; -import { FieldVisibilityGroup } from "./FieldVisibilityGroup"; -import { StyleAndFontTable } from "./StyleAndFontTable"; -import { BloomSubscriptionIndicatorIconAndText } from "../../react_components/requiresSubscription"; -import { useGetFeatureStatus } from "../../react_components/featureStatus"; -import { isLegacyThemeName } from "./appearanceThemeUtils"; - -let isOpenAlready = false; - -type IPageStyle = { label: string; value: string }; -type IPageStyles = Array; -type IAppearanceUIOptions = { - firstPossiblyLegacyCss?: string; - migratedTheme?: string; - themeNames: IPageStyles; -}; - -// Stuff we find in the appearance property of the object we get from the book/settings api. -// Not yet complete -export interface IAppearanceSettings { - cssThemeName: string; -} - -// Stuff we get from the book/settings api. -// Not yet complete -export interface IBookSettings { - appearance?: IAppearanceSettings; - firstPossiblyLegacyCss?: string; -} - -// Stuff we get from the book/settings/overrides api. -// The branding and xmatter objects contain the corresponding settings, -// using the same keys as appearance.json. Currently the values are all -// booleans. -interface IOverrideInformation { - branding: object; - xmatter: object; - brandingName: string; - xmatterName: string; -} - -// Should stay in sync with AppearanceSettings.PageNumberPosition -enum PageNumberPosition { - Automatic = "automatic", - Left = "left", - Center = "center", - Right = "right", - Hidden = "hidden", -} - -export const BookSettingsDialog: React.FunctionComponent<{ - initiallySelectedGroupIndex?: number; -}> = (props) => { - const { closeDialog, propsForBloomDialog } = useSetupBloomDialog({ - initiallyOpen: true, - dialogFrameProvidedExternally: false, - }); - - const appearanceUIOptions: IAppearanceUIOptions = - useApiObject( - "book/settings/appearanceUIOptions", - { - themeNames: [], - }, - ); - // If we pass a new default value to useApiObject on every render, it will query the host - // every time and then set the result, which triggers a new render, making an infinite loop. - const defaultOverrides = React.useMemo(() => { - return { - xmatter: {}, - branding: {}, - xmatterName: "", - brandingName: "", - }; - }, []); - - const overrideInformation: IOverrideInformation | undefined = - useApiObject( - "book/settings/overrides", - defaultOverrides, - ); - - const [pageSizeSupportsFullBleed] = useApiBoolean( - "book/settings/pageSizeSupportsFullBleed", - true, - ); - - const xmatterLockedBy = useL10n( - "Locked by {0} Front/Back matter", - "BookSettings.LockedByXMatter", - "", - overrideInformation?.xmatterName, - ); - - const brandingLockedBy = useL10n( - "Locked by {0} Branding", - "BookSettings.LockedByBranding", - "", - overrideInformation?.brandingName, - ); - - const coverLabel = useL10n("Cover", "BookSettings.CoverGroupLabel"); - const contentPagesLabel = useL10n( - "Content Pages", - "BookSettings.ContentPagesGroupLabel", - ); - const printPublishingLabel = useL10n( - "Print Publishing", - "BookSettings.PrintPublishingGroupLabel", - ); - const languagesToShowNormalSubgroupLabel = useL10n( - "Languages to show in normal text boxes", - "BookSettings.NormalTextBoxLangsLabel", - "", - ); - const themeLabel = useL10n("Page Theme", "BookSettings.PageThemeLabel", ""); - const themeDescription = useL10n( - "", // will be translated or the English will come from the xliff - "BookSettings.Theme.Description", - ); - /* can't use this yet. See https://issues.bloomlibrary.org/youtrack/issue/BL-13094/Enable-links-in-Config-r-Descriptions - const pageThemeDescriptionElement = ( - - Page Themes are a bundle of margins, borders, and other page settings. For information about each theme, see [Page Themes Catalog]. - - ); - */ - - const coverBackgroundColorLabel = useL10n( - "Background Color", - "Common.BackgroundColor", - ); - - const whatToShowOnCoverLabel = useL10n( - "Front Cover", - "BookSettings.WhatToShowOnCover", - ); - - const showLanguageNameLabel = useL10n( - "Show Language Name", - "BookSettings.ShowLanguageName", - ); - const showTopicLabel = useL10n("Show Topic", "BookSettings.ShowTopic"); - const showCreditsLabel = useL10n( - "Show Credits", - "BookSettings.ShowCredits", - ); - const _frontAndBackMatterLabel = useL10n( - "Front & Back Matter", - "BookSettings.FrontAndBackMatter", - ); - const pageNumbersLabel = useL10n( - "Page Numbers", - "BookSettings.PageNumbers", - ); - const pageNumberLocationNote = useL10n( - "Note: some Page Themes may not know how to change the location of the Page Number.", - "BookSettings.PageNumberLocationNote", - ); - const pageNumberPositionAutomaticLabel = useL10n( - "(Automatic)", - "BookSettings.PageNumbers.Automatic", - ); - const pageNumberPositionLeftLabel = useL10n( - "Left", - "BookSettings.PageNumbers.Left", - ); - const pageNumberPositionCenterLabel = useL10n( - "Center", - "BookSettings.PageNumbers.Center", - ); - const pageNumberPositionRightLabel = useL10n( - "Right", - "BookSettings.PageNumbers.Right", - ); - const pageNumberPositionHiddenLabel = useL10n( - "Hidden", - "BookSettings.PageNumbers.Hidden", - ); - - const _frontAndBackMatterDescription = useL10n( - "Normally, books use the front & back matter pack that is chosen for the entire collection. Using this setting, you can cause this individual book to use a different one.", - "BookSettings.FrontAndBackMatter.Description", - ); - const resolutionLabel = useL10n("Resolution", "BookSettings.Resolution"); - const bloomPubLabel = useL10n("eBooks", "PublishTab.bloomPUBButton"); // reuse the same string localized for the Publish tab - - const advancedLayoutLabel = useL10n( - "Advanced Layout", - "BookSettings.AdvancedLayoutLabel", - ); - const textPaddingLabel = useL10n( - "Text Padding", - "BookSettings.TopLevelTextPaddingLabel", - ); - const textPaddingDescription = useL10n( - "Smart spacing around text boxes. Works well for simple pages, but may not suit custom layouts.", - "BookSettings.TopLevelTextPadding.Description", - ); - const textPaddingDefaultLabel = useL10n( - "Default (set by Theme)", - "BookSettings.TopLevelTextPadding.DefaultLabel", - ); - const textPadding1emLabel = useL10n( - "1 em (font size)", - "BookSettings.TopLevelTextPadding.1emLabel", - ); - - const gutterLabel = useL10n("Page Gutter", "BookSettings.Gutter.Label"); - const gutterDescription = useL10n( - "Extra space between pages near the book spine. Increase this for books with many pages to ensure text isn't lost in the binding. This gap is applied to each side of the spine.", - "BookSettings.Gutter.Description", - ); - const gutterDefaultLabel = useL10n( - "Default (set by Theme)", - "BookSettings.Gutter.DefaultLabel", - ); - - const coverIsImageLabel = useL10n( - "Fill the front cover with a single image", - "BookSettings.CoverIsImage", - ); - const coverIsImageDescription = useL10n( - "Replace the front cover content with a single full-bleed image. See [Full Page Cover Images](https://docs.bloomlibrary.org/full-page-cover-images) for information on sizing your image to fit.", - "BookSettings.CoverIsImage.Description.V2", - ); - - const fullBleedLabel = useL10n( - "Use full bleed page layout", - "BookSettings.FullBleed", - ); - const fullBleedDescription = useL10n( - "Enable full bleed layout for printing. This turns on the [Print Bleed](https://en.wikipedia.org/wiki/Bleed_%28printing%29) indicators on paper layouts. See [Full Bleed Layout](https://docs.bloomlibrary.org/full-bleed) for more information.", - "BookSettings.FullBleed.Description", - ); - - // This is a helper function to make it easier to pass the override information - function getAdditionalProps(subPath: string): { - path: string; - overrideValue: T; - overrideDescription?: string; - } { - // some properties will be overridden by branding and/or xmatter - const xmatterOverride: T | undefined = - overrideInformation?.xmatter?.[subPath]; - const brandingOverride = overrideInformation?.branding?.[subPath]; - const override = xmatterOverride ?? brandingOverride; - // nb: xmatterOverride can be boolean, hence the need to spell out !==undefined - let description = - xmatterOverride !== undefined ? xmatterLockedBy : undefined; - if (!description) { - // xmatter wins if both are present - description = - brandingOverride !== undefined ? brandingLockedBy : undefined; - } - // make a an object that can be spread as props in any of the Configr controls - return { - path: "appearance." + subPath, - overrideValue: override as T, - // if we're disabling all appearance controls (e.g. because we're in legacy), don't list a second reason for this overload - overrideDescription: appearanceDisabled ? "" : description, - }; - } - - const [settingsString] = useApiStringState( - "book/settings", - "{}", - () => propsForBloomDialog.open, - ); - - const [settings, setSettings] = React.useState( - undefined, - ); - - const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< - string | IBookSettings | undefined - >(undefined); - - const normalizeConfigrSettings = ( - settingsValue: string | IBookSettings | undefined, - ): IBookSettings | undefined => { - if (!settingsValue) { - return undefined; - } - if (typeof settingsValue === "string") { - return JSON.parse(settingsValue) as IBookSettings; - } - return settingsValue; - }; - - const [appearanceDisabled, setAppearanceDisabled] = React.useState(false); - - // We use state here to allow the dialog UI to update without permanently changing the settings - // and getting notified of those changes. The changes are persisted when the user clicks OK - // (except for the button to delete customBookStyles.css, which is done immediately). - // A downside of this is that when we delete customBookStyles.css, we don't know whether - // the result will be no conflicts or that customCollectionStyles.css will now be the - // firstPossiblyLegacyCss. For now it just behaves as if there are now no conflicts. - // One possible approach is to have the server return the new firstPossiblyLegacyCss - // as the result of the deleteCustomBookStyles call. - const [theme, setTheme] = React.useState(""); - const [firstPossiblyLegacyCss, setFirstPossiblyLegacyCss] = - React.useState(""); - const [migratedTheme, setMigratedTheme] = React.useState(""); - - React.useEffect(() => { - if (settingsString === "{}") { - return; // leave settings as undefined - } - if (typeof settingsString === "string") { - setSettings(JSON.parse(settingsString)); - } else { - setSettings(settingsString); - } - }, [settingsString]); - - React.useEffect(() => { - setFirstPossiblyLegacyCss( - appearanceUIOptions?.firstPossiblyLegacyCss ?? "", - ); - setMigratedTheme(appearanceUIOptions?.migratedTheme ?? ""); - }, [appearanceUIOptions]); - - const bookSettingsTitle = useL10n("Book Settings", "BookSettings.Title"); - React.useEffect(() => { - if (settings?.appearance) { - const liveSettings = - normalizeConfigrSettings(settingsToReturnLater) ?? settings; - // when we're in legacy, we're just going to disable all the appearance controls - setAppearanceDisabled( - isLegacyThemeName(liveSettings?.appearance?.cssThemeName), - ); - setTheme(liveSettings?.appearance?.cssThemeName ?? ""); - } - }, [settings, settingsToReturnLater]); - - const deleteCustomBookStyles = () => { - post( - `book/settings/deleteCustomBookStyles?file=${firstPossiblyLegacyCss}`, - ); - setFirstPossiblyLegacyCss(""); - setMigratedTheme(""); - }; - - const tierAllowsFullPageCoverImage = - useGetFeatureStatus("fullPageCoverImage")?.enabled; - - const tierAllowsFullBleed = useGetFeatureStatus("PrintshopReady")?.enabled; - - function saveSettingsAndCloseDialog() { - const settingsToPost = normalizeConfigrSettings(settingsToReturnLater); - if (settingsToPost) { - // If nothing changed, we don't get any...and don't need to make this call. - postJson("book/settings", settingsToPost); - } - isOpenAlready = false; - closeDialog(); - // todo: how do we make the pageThumbnailList reload? It's in a different browser, so - // we can't use a global. It listens to websocket, but we currently can only listen, - // we cannot send. - } - - return ( - { - isOpenAlready = false; - closeDialog(); - }} - draggable={false} - maxWidth={false} - > - - - {settings && ( - { - setSettingsToReturnLater(s); - //setSettings(s); - }} - initiallySelectedTopLevelPageIndex={ - props.initiallySelectedGroupIndex - } - > - - {appearanceDisabled && ( - - -
- The selected page theme does not - support the following settings. -
-
-
- )} - -
- ( - `coverIsImage`, - )} - disabled={ - appearanceDisabled || - !tierAllowsFullPageCoverImage - } - /> -
- -
-
- - - ( - `cover-languageName-show`, - )} - /> - ( - `cover-topic-show`, - )} - /> - ( - `cover-creditsRow-show`, - )} - /> -
- - ( - `cover-background-color`, - )} - /> - - {/* - - - - */} -
- - { - // This group of four possible messages...sometimes none of them shows, so there are five options... - // is very similar to the one in BookInfoIndicator.tsx. If you change one, you may need to change the other. - // In particular, the logic for which to show and the text of the messages should be kept in sync. - // I'm not seeing a clean way to reuse the logic. Some sort of higher-order component might work, - // but I don't think the logic is complex enough to be worth it, when only used in two places. - } - {firstPossiblyLegacyCss.length > 0 && - isLegacyThemeName(theme) && ( - - - - - - )} - {firstPossiblyLegacyCss === - "customBookStyles.css" && - !isLegacyThemeName(theme) && ( - - -
- {migratedTheme ? ( - - ) : ( - - )} -
- deleteCustomBookStyles() - } - > - -
- Delete{" "} - {firstPossiblyLegacyCss} -
-
-
-
-
- )} - {firstPossiblyLegacyCss.length > 0 && - firstPossiblyLegacyCss !== - "customBookStyles.css" && - !isLegacyThemeName(theme) && ( - - - - - - )} - - {/* Wrapping these two in a div prevents Config-R from sticking a divider between them */} -
- { - return { - label: x.label, - value: x.value, - }; - }, - )} - description={themeDescription} - /> - {appearanceDisabled && ( - -
- The selected page theme does not - support the following settings. -
-
- )} -
- ( - `pageNumber-position`, - )} - options={[ - { - label: pageNumberPositionAutomaticLabel, - value: PageNumberPosition.Automatic, - }, - { - label: pageNumberPositionLeftLabel, - value: PageNumberPosition.Left, - }, - { - label: pageNumberPositionCenterLabel, - value: PageNumberPosition.Center, - }, - { - label: pageNumberPositionRightLabel, - value: PageNumberPosition.Right, - }, - { - label: "--", - value: "--", - }, - { - label: pageNumberPositionHiddenLabel, - value: PageNumberPosition.Hidden, - }, - ]} - description={pageNumberLocationNote} - /> -
- - - - - ( - `topLevel-text-padding`, - )} - /> - ( - `page-gutter`, - )} - /> - -
- - -
- ( - `fullBleed`, - )} - disabled={ - !tierAllowsFullBleed || - !pageSizeSupportsFullBleed - } - /> -
- -
-
-
-
- - {/* note that this is used for bloomPUB and ePUB, but we don't have separate settings so we're putting them in bloomPUB and leaving it to c# code to use it for ePUB as well. */} - - - - - - - - -
-

- When you publish a book to the - web or as an ebook, Bloom will - flag any problematic fonts. For - example, we cannot legally host - most Microsoft fonts on - BloomLibrary.org. -

-

- The following table shows where - fonts have been used. -

-
-
- -
-
-
-
- )} -
- - - - -
- ); -}; - -type Resolution = { - maxWidth: number; - maxHeight: number; -}; - -const BloomResolutionSlider: React.FunctionComponent< - React.PropsWithChildren<{ - path: string; - label: string; - }> -> = (props) => { - return ( -
- - control={BloomResolutionSliderInner} - {...props} - > -
- Bloom reduces images to a maximum size to make books easier to - view over poor internet connections and take up less space on - phones. -
-
- ); -}; - -const BloomResolutionSliderInner: React.FunctionComponent<{ - value: Resolution; - onChange: (value: Resolution) => void; -}> = (props) => { - const sizes = [ - { l: "Small", w: 600, h: 600 }, - { l: "HD", w: 1280, h: 720 }, - { l: "Full HD", w: 1920, h: 1080 }, - { l: "4K", w: 3840, h: 2160 }, - ]; - let currentIndex = sizes.findIndex((x) => x.w === props.value.maxWidth); - if (currentIndex === -1) { - currentIndex = 1; // See BL-12803. - } - const current = sizes[currentIndex]; - const currentLabel = useL10n( - current.l, - `BookSettings.eBook.Image.MaxResolution.${current.l}`, - ); - - return ( - -
- {`${currentLabel}`} - { - return `${current.w}x${current.h}`; - }} - onChange={(e, value) => { - props.onChange({ - maxWidth: sizes[value as number].w, - maxHeight: sizes[value as number].h, - }); - }} - valueLabelDisplay="auto" - > -
-
- ); -}; - -export function showBookSettingsDialog(initiallySelectedGroupIndex?: number) { - // once Bloom's tab bar is also in react, it won't be possible - // to open another copy of this without closing it first, but - // for now, we need to prevent that. - if (!isOpenAlready) { - isOpenAlready = true; - ShowEditViewDialog( - , - ); - } -} - -export const MessageUsingLegacyThemeWithIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The {0} stylesheet of this book is incompatible with modern themes. - Bloom is using it because the book is using the Legacy-5-6 theme. - Click [here] for more information. - - ); -}; - -export const MessageUsingMigratedThemeInsteadOfIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( -
- Bloom found a known version of {props.fileName} in this book and - replaced it with a modern theme. You can delete it unless you still - need to publish the book from an earlier version of Bloom. -
- ); -}; - -export const MessageIgnoringIncompatibleCssCanDelete: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The - {props.fileName} stylesheet of this book is incompatible with modern - themes. Bloom is currently ignoring it. If you don't need those - customizations any more, you can delete your - {props.fileName}. Click [here] for more information. - - ); -}; -export const MessageIgnoringIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The {props.fileName} stylesheet of this book is incompatible with - modern themes. Bloom is currently ignoring it. Click [here] for more - information. - - ); -}; - -const ColorPickerForConfigr: React.FunctionComponent<{ - value: string; - disabled: boolean; - onChange: (value: string) => void; -}> = (props) => { - const coverBackgroundColorLabel = useL10n( - "Background Color", - "Common.BackgroundColor", - ); - - return ( - { - if (dialogResult === DialogResult.OK) props.onChange(newColor); - }} - /> - ); -}; diff --git a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less index 308d3e85bd86..c255d42e0b7a 100644 --- a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less +++ b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less @@ -163,9 +163,10 @@ top: @ToggleVerticalOffset; width: 100%; display: flex; - justify-content: end; + justify-content: space-between; box-sizing: border-box; } + .origami-toggle { cursor: pointer; margin-right: 19px; @@ -178,6 +179,39 @@ display: inline; } } +.page-settings-button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + width: auto; + height: 24px; + padding: 0 4px; + margin-right: 8px; + border: none; + background-color: transparent; + cursor: pointer; + color: @bloom-purple; + white-space: nowrap; + font-size: 12px; + line-height: 1; + + &:hover { + opacity: 0.8; + } + + svg { + width: 20px; + height: 20px; + flex-shrink: 0; + } + + .page-settings-button-label { + font-size: 11px; + line-height: 1; + white-space: nowrap; + } +} // here follows the inner workings of the toggle .onoffswitch { diff --git a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx index e0b35450fb84..87062c38e57b 100644 --- a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx @@ -72,7 +72,7 @@ import { kBloomCanvasSelector, } from "../toolbox/canvas/canvasElementUtils"; import { getString, post, useApiObject } from "../../utils/bloomApi"; -import { ILanguageNameValues } from "../bookSettings/FieldVisibilityGroup"; +import { ILanguageNameValues } from "../bookAndPageSettings/FieldVisibilityGroup"; interface IMenuItemWithSubmenu extends ILocalizableMenuItemProps { subMenu?: ILocalizableMenuItemProps[]; diff --git a/src/BloomBrowserUI/bookEdit/js/origami.ts b/src/BloomBrowserUI/bookEdit/js/origami.ts index 6da140e5121f..b7115572a565 100644 --- a/src/BloomBrowserUI/bookEdit/js/origami.ts +++ b/src/BloomBrowserUI/bookEdit/js/origami.ts @@ -1,5 +1,3 @@ -// not yet: neither bloomEditing nor this is yet a module import {SetupImage} from './bloomEditing'; -/// import { SetupImage } from "./bloomImages"; import { kBloomCanvasClass } from "../toolbox/canvas/canvasElementUtils"; import "../../lib/split-pane/split-pane.js"; @@ -23,47 +21,91 @@ export function setupOrigami() { const isCanvasFeatureEnabled: boolean = canvasFeatureStatus?.enabled || false; const customPages = document.getElementsByClassName("customPage"); - if (customPages.length > 0) { - const width = customPages[0].clientWidth; - const origamiControl = getAbovePageControlContainer() - .append( - createTypeSelectors( - isWidgetFeatureEnabled, - isCanvasFeatureEnabled, - ), - ) - .append(createTextBoxIdentifier()); + const bloomPage = document.getElementsByClassName( + "bloom-page", + )[0] as HTMLElement | undefined; + const pageWidth = bloomPage?.clientWidth; + if (pageWidth !== undefined) { + const showOrigamiControls = customPages.length > 0; + const pageControlContainer = + getAbovePageControlContainer(showOrigamiControls); + + if (showOrigamiControls) { + pageControlContainer + .append( + createTypeSelectors( + isWidgetFeatureEnabled, + isCanvasFeatureEnabled, + ), + ) + .append(createTextBoxIdentifier()); + } + // The order of this is not important in most ways, since it is positioned absolutely. // However, we position the page label, also absolutely, in the same screen area, and // we want it on top of origami control, so that in template pages the user can edit it. // The page label is part of the page, so we want the page to come after the origami control. // (Could also do this with z-order, but I prefer to do what I can by ordering elements, // and save z-order for when it is really needed.) - $("#page-scaling-container").prepend(origamiControl); + $("#page-scaling-container").prepend(pageControlContainer); // The container width is set to 100% in the CSS, but we need to // limit it to no more than the actual width of the page. const toggleContainer = $(".above-page-control-container").get( 0, ); - toggleContainer.style.maxWidth = width + "px"; + if (toggleContainer instanceof HTMLElement) { + toggleContainer.style.maxWidth = pageWidth + "px"; + } } // I'm not clear why the rest of this needs to wait until we have // the two results, but none of the controls shows up if we leave it all // outside the bloomApi functions. $(".origami-toggle .onoffswitch").change(layoutToggleClickHandler); + $(".page-settings-button").click(pageSettingsButtonClickHandler); if ($(".customPage .marginBox.origami-layout-mode").length) { setupLayoutMode(); $("#myonoffswitch").prop("checked", true); } - $(".customPage, .above-page-control-container") - .find("*[data-i18n]") - .localize(); + const localizableElements = $( + ".customPage, .above-page-control-container", + ).find("*[data-i18n]"); + // In some dev/runtime paths the jQuery localize plugin is not loaded. + try { + if (typeof localizableElements.localize === "function") { + localizableElements.localize(); + } + } catch (error) { + console.warn( + "Origami localization failed; continuing with default labels.", + error, + ); + } + + ensurePageSettingsButtonHasIcon(); }); }); } +function ensurePageSettingsButtonHasIcon() { + $(".page-settings-button").each((_index, element) => { + const button = $(element); + const labelText = $.trim(button.text()) || "Page Settings"; + button.empty(); + button.append($(getPageSettingsButtonIconHtml())); + button.append( + $("").text( + labelText, + ), + ); + }); +} + +function getPageSettingsButtonIconHtml(): string { + return ``; +} + export function cleanupOrigami() { // Otherwise, we get a new one each time the page is loaded $(".split-pane-resize-shim").remove(); @@ -338,9 +380,8 @@ function getSplitPaneComponentInner() { return spci; } -function getAbovePageControlContainer(): JQuery { - // for dragActivities we don't want the origami control, but we still make the - // wrapper so that the dragActivity can put a different control in it. +function getAbovePageControlContainer(showOrigamiControls: boolean): JQuery { + // For dragActivities we reserve this wrapper for the game controls. // Note: We also have to disable the Choose Different layout option in // the right click menu, in PageListView.cs if ( @@ -350,9 +391,19 @@ function getAbovePageControlContainer(): JQuery { ) { return $("
"); } + + if (!showOrigamiControls) { + return $( + `
\ +${getPageSettingsButtonHtml()}\ +
`, + ); + } + return $( - "\ + `\
\ +${getPageSettingsButtonHtml()}\
\
Change Layout
\
\ @@ -363,10 +414,19 @@ function getAbovePageControlContainer(): JQuery { \
\
\ -
", +`, ); } +function getPageSettingsButtonHtml(): string { + return ``; +} + +function pageSettingsButtonClickHandler(e: Event) { + e.preventDefault(); + post("editView/showPageSettingsDialog"); +} + function getButtons() { const buttons = $( "
", diff --git a/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts b/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts index efb7065bd324..43732316ab54 100644 --- a/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts +++ b/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts @@ -13,9 +13,9 @@ to hide the details so that we can easily change it later. */ -import { IPageFrameExports } from "../editablePage"; -import { IWorkspaceExports } from "../workspaceRoot"; -import { IToolboxFrameExports } from "../toolbox/toolboxBootstrap"; +import type { IPageFrameExports } from "../editablePage"; +import type { IWorkspaceExports } from "../workspaceRoot"; +import type { IToolboxFrameExports } from "../toolbox/toolboxBootstrap"; export function getToolboxBundleExports(): IToolboxFrameExports | null { const frameWindow = getFrame("toolbox") as diff --git a/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx b/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx index add78f013d77..ef4e62b9a0f2 100644 --- a/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx +++ b/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx @@ -44,6 +44,10 @@ export const PageThumbnail: React.FunctionComponent<{ // a fast desktop for a complex page...mainly because of XhtmlToHtml conversion. // So we do it lazily after setting up the initial framework of pages. const requestPage = useCallback(() => { + if (props.page.key === "placeholder") { + pendingPageRequestCount--; + return; + } // We don't want a lot of page requests running at the same time. // There are various limits on simultaneous requests, including // the number of threads in the BloomServer and the number of active diff --git a/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx b/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx index 53b4b11adf6f..1f3e9c729290 100644 --- a/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx +++ b/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx @@ -78,6 +78,16 @@ interface IContextMenuPoint { pageId: string; } +const normalizeBookDisplayAttributes = ( + attributes: Record, +): Record => { + const normalized: Record = {}; + Object.entries(attributes).forEach(([key, value]) => { + normalized[key.startsWith("data-") ? key.toLowerCase() : key] = value; + }); + return normalized; +}; + // This map goes from page ID to a callback that we get from the page thumbnail // which should be called when the main Bloom program informs us that // the thumbnail needs to be updated. @@ -107,6 +117,11 @@ const PageList: React.FunctionComponent<{ pageLayout: string }> = (props) => { "pageList/bookAttributesThatMayAffectDisplay", {}, ); + const normalizedBookDisplayAttributes = React.useMemo( + () => + normalizeBookDisplayAttributes(bookAttributesThatMayAffectDisplay), + [bookAttributesThatMayAffectDisplay], + ); const pageMenuDefinition: IPageMenuItem[] = [ { @@ -464,10 +479,7 @@ const PageList: React.FunctionComponent<{ pageLayout: string }> = (props) => { }; return ( -
+
= 50) { + throw new Error("Toolbox accordion did not initialize."); + } + window.setTimeout(() => setCurrentTool(toolID, retryCount + 1), 0); + return; + } const accordionHeaders = toolbox.find("> h3"); if (toolID) { diff --git a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts index fb38193e98fe..0a26cc9e9472 100644 --- a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts +++ b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts @@ -21,6 +21,7 @@ export interface IWorkspaceExports { task: (toolboxFrameExports: IToolboxFrameExports) => unknown, ); getModalDialogContainer(): HTMLElement | null; + ShowEditViewDialog(dialog: FunctionComponentElement): void; showConfirmDialog(props: IConfirmDialogProps): void; showColorPickerDialog(props: IColorPickerDialogProps): void; hideColorPickerDialog(): void; @@ -59,7 +60,7 @@ import { showPageChooserDialog } from "../pageChooser/PageChooserDialog"; export { showPageChooserDialog }; import "../lib/errorHandler"; -import { showBookSettingsDialog } from "./bookSettings/BookSettingsDialog"; +import { showBookSettingsDialog } from "./bookAndPageSettings/BookAndPageSettingsDialog"; export { showBookSettingsDialog }; import { showRegistrationDialogForEditTab } from "../react_components/registration/registrationDialog"; export { showRegistrationDialogForEditTab as showRegistrationDialog }; @@ -269,15 +270,23 @@ export function showEditViewTopicChooserDialog() { showTopicChooserDialog(); } export function showEditViewBookSettingsDialog( - initiallySelectedGroupIndex?: number, + initiallySelectedPageKey?: string, ) { - showBookSettingsDialog(initiallySelectedGroupIndex); + showBookSettingsDialog(initiallySelectedPageKey); } export function showAboutDialogFromWorkspaceRoot() { showAboutDialog(); } +export function showEditViewPageSettingsDialog() { + showBookSettingsDialog("colors"); +} + +export function showAboutDialogInEditTab() { + showAboutDialog(); +} + export function showRequiresSubscriptionDialog(featureName: string): void { showRequiresSubscriptionDialogInEditView(featureName); } @@ -434,6 +443,8 @@ interface WorkspaceBundleApi { showEditViewTopicChooserDialog: typeof showEditViewTopicChooserDialog; showEditViewBookSettingsDialog: typeof showEditViewBookSettingsDialog; showAboutDialogFromWorkspaceRoot: typeof showAboutDialogFromWorkspaceRoot; + showEditViewPageSettingsDialog: typeof showEditViewPageSettingsDialog; + showAboutDialogInEditTab: typeof showAboutDialogInEditTab; showRequiresSubscriptionDialog: typeof showRequiresSubscriptionDialog; showRegistrationDialogFromWorkspaceRoot: typeof showRegistrationDialogFromWorkspaceRoot; setWorkspaceMode: typeof setWorkspaceMode; @@ -473,6 +484,8 @@ window.workspaceBundle = { showEditViewTopicChooserDialog, showEditViewBookSettingsDialog, showAboutDialogFromWorkspaceRoot, + showEditViewPageSettingsDialog, + showAboutDialogInEditTab, showRequiresSubscriptionDialog, showRegistrationDialogFromWorkspaceRoot, setWorkspaceMode, diff --git a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx index 9a313beca1c8..d44c69b4f4c7 100644 --- a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx +++ b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx @@ -1,6 +1,7 @@ import { css } from "@emotion/react"; import * as React from "react"; import { + ConfigrValues, ConfigrGroup, ConfigrPage, ConfigrPane, @@ -27,7 +28,7 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { propsForBloomDialog, } = useEventLaunchedBloomDialog("CollectionSettingsDialog"); - const [settings, setSettings] = React.useState( + const [settings, setSettings] = React.useState( undefined, ); @@ -41,29 +42,17 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { }, [propsForBloomDialog.open]); const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< - string | object | undefined + ConfigrValues | undefined >(undefined); - - const normalizeConfigrSettings = ( - settingsValue: string | object | undefined, - ): object | undefined => { - if (!settingsValue) { - return undefined; - } - if (typeof settingsValue === "string") { - return JSON.parse(settingsValue) as object; - } - return settingsValue; - }; // Parse the settings JSON for Configr's initial values once it arrives. React.useEffect(() => { if (settingsString === "{}") { return; // leave settings as undefined } if (typeof settingsString === "string") { - setSettings(JSON.parse(settingsString)); + setSettings(JSON.parse(settingsString) as ConfigrValues); } else { - setSettings(settingsString); + setSettings(settingsString as ConfigrValues); } }, [settingsString]); @@ -150,11 +139,11 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { { - const settingsToPost = normalizeConfigrSettings( - settingsToReturnLater, - ); - if (settingsToPost) { - postJson("collection/settings", settingsToPost); + if (settingsToReturnLater) { + postJson( + "collection/settings", + settingsToReturnLater, + ); } closeDialog(); }} diff --git a/src/BloomBrowserUI/collectionsTab/BookButton.tsx b/src/BloomBrowserUI/collectionsTab/BookButton.tsx index 02cca9d15dea..f7c0f621067a 100644 --- a/src/BloomBrowserUI/collectionsTab/BookButton.tsx +++ b/src/BloomBrowserUI/collectionsTab/BookButton.tsx @@ -28,7 +28,7 @@ import { makeMenuItems, MenuItemSpec } from "./menuHelpers"; import DeleteIcon from "@mui/icons-material/Delete"; import { useL10n } from "../react_components/l10nHooks"; import SettingsIcon from "@mui/icons-material/Settings"; -import { showBookSettingsDialog } from "../bookEdit/bookSettings/BookSettingsDialog"; +import { showBookSettingsDialog } from "../bookEdit/bookAndPageSettings/BookAndPageSettingsDialog"; import { BookOnBlorgBadge } from "../react_components/BookOnBlorgBadge"; export const bookButtonHeight = 120; diff --git a/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx b/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx index 728ac68ae30f..c7a424a82080 100644 --- a/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx +++ b/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx @@ -294,7 +294,6 @@ export const CollectionsTabBookPane: React.FunctionComponent<{ padding: 10px; background-color: ${kDarkestBackground}; `} - {...props} // allows defining more css rules from container >
void; + onChangeComplete?: (color: ColorResult) => void; // Needed for tooltip on Alpha slider currentOpacity: number; diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx index f4d3a8f4926d..657ffe25f65b 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx @@ -1,13 +1,16 @@ import { css } from "@emotion/react"; import * as React from "react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ColorResult, RGBColor } from "react-color"; import BloomSketchPicker from "./bloomSketchPicker"; import ColorSwatch, { IColorInfo } from "./colorSwatch"; import tinycolor from "tinycolor2"; import { HexColorInput } from "./hexColorInput"; import { useL10n } from "../l10nHooks"; -import { Typography } from "@mui/material"; +import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; +import ColorizeIcon from "@mui/icons-material/Colorize"; +import { getColorInfoFromSpecialNameOrColorString } from "./bloomPalette"; // We are combining parts of the 'react-color' component set with our own list of swatches. // The reason for using our own swatches is so we can support swatches with gradients and alpha. @@ -15,31 +18,153 @@ interface IColorPickerProps { transparency?: boolean; noGradientSwatches?: boolean; onChange: (color: IColorInfo) => void; + onChangeComplete?: (color: IColorInfo) => void; currentColor: IColorInfo; swatchColors: IColorInfo[]; includeDefault?: boolean; onDefaultClick?: () => void; + onEyedropperActiveChange?: (active: boolean) => void; + eyedropperBackdropSelector?: string; //defaultColor?: IColorInfo; will eventually need this } +type EyeDropperResult = { sRGBHex: string }; +type EyeDropper = { open: () => Promise }; +type EyeDropperConstructor = { new (): EyeDropper }; + +const getEyeDropperConstructor = (): EyeDropperConstructor | undefined => { + let iframeWindow: + | (Window & { EyeDropper?: EyeDropperConstructor }) + | null + | undefined; + try { + const iframe = parent.window.document.getElementById( + "page", + ) as HTMLIFrameElement | null; + iframeWindow = iframe?.contentWindow as + | (Window & { EyeDropper?: EyeDropperConstructor }) + | null; + } catch { + iframeWindow = undefined; + } + const topWindow = window as Window & { EyeDropper?: EyeDropperConstructor }; + return iframeWindow?.EyeDropper ?? topWindow.EyeDropper; +}; + +const kEyedropperBackdropStyleId = "bloom-eyedropper-backdrop-style"; +const defaultEyedropperBackdropSelector = ".MuiBackdrop-root"; + +const setEyedropperBackdropTransparent = ( + selector: string | undefined, + enabled: boolean, +): void => { + const resolvedSelector = selector ?? defaultEyedropperBackdropSelector; + if (!resolvedSelector) { + return; + } + + const existing = document.getElementById( + kEyedropperBackdropStyleId, + ) as HTMLStyleElement | null; + + if (enabled) { + if (existing && existing.textContent?.includes(resolvedSelector)) { + return; + } + const style = existing ?? document.createElement("style"); + style.id = kEyedropperBackdropStyleId; + style.textContent = ` + ${resolvedSelector} { + background-color: transparent !important; + } + `; + if (!existing) { + document.head.appendChild(style); + } + } else if (existing) { + existing.remove(); + } +}; + +const setPageScalingDisabled = (disabled: boolean): (() => void) => { + if (!disabled) { + return () => {}; + } + + // Bloom applies page zoom using a transform on this element (see editViewFrame.ts setZoom()). + // WebView2's EyeDropper sampling can be offset when the page content is transformed. + const iframe = parent.window.document.getElementById( + "page", + ) as HTMLIFrameElement | null; + const iframeDoc = iframe?.contentWindow?.document; + const container = iframeDoc?.getElementById( + "page-scaling-container", + ) as HTMLElement | null; + + if (!container) { + return () => {}; + } + + const previousTransform = container.style.transform; + const previousWidth = container.style.width; + const previousTransformOrigin = container.style.transformOrigin; + + container.style.transform = ""; + container.style.width = ""; + container.style.transformOrigin = ""; + + return () => { + container.style.transform = previousTransform; + container.style.width = previousWidth; + container.style.transformOrigin = previousTransformOrigin; + }; +}; + export const ColorPicker: React.FunctionComponent = ( props, ) => { - const [colorChoice, setColorChoice] = useState(props.currentColor); + const [eyedropperActive, setEyedropperActive] = useState(false); + const mountedRef = useRef(true); + const backdropSelector = + props.eyedropperBackdropSelector ?? defaultEyedropperBackdropSelector; + const hasNativeEyedropper = !!getEyeDropperConstructor(); + + // Track mount state so we don't update state after unmount, and to ensure any temporary + // backdrop overrides are removed if the component unmounts while the eyedropper is active. + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + setEyedropperBackdropTransparent(backdropSelector, false); + }; + }, [backdropSelector]); const defaultStyleLabel = useL10n( "Default for style", "EditTab.DirectFormatting.labelForDefaultColor", ); - const changeColor = (swatchColor: IColorInfo) => { - setColorChoice(swatchColor); - props.onChange(swatchColor); + const cloneColor = (color: IColorInfo): IColorInfo => { + return { + ...color, + colors: [...color.colors], + }; + }; + + const changeColor = ( + swatchColor: IColorInfo, + options?: { complete?: boolean }, + ) => { + const clonedColor = cloneColor(swatchColor); + props.onChange(clonedColor); + if (options?.complete) { + props.onChangeComplete?.(clonedColor); + } }; // Handler for when the user clicks on a swatch at the bottom of the picker. const handleSwatchClick = (swatchColor: IColorInfo) => () => { - changeColor(swatchColor); + changeColor(swatchColor, { complete: true }); }; // Handler for when the user clicks/drags in the BloomSketchPicker (Saturation, Hue and Alpha). @@ -48,13 +173,26 @@ export const ColorPicker: React.FunctionComponent = ( changeColor(newColor); }; + const handlePickerChangeComplete = (color: ColorResult) => { + const newColor = getColorInfoFromColorResult(color, ""); + props.onChangeComplete?.(cloneColor(newColor)); + }; + // Handler for when the user changes the hex code value (including pasting). const handleHexCodeChange = (hexColor: string) => { + let colorOnly = hexColor; + let newOpacity = props.currentColor.opacity; + + if (props.transparency && /^#[0-9A-Fa-f]{8}$/.test(hexColor)) { + colorOnly = hexColor.substring(0, 7); + newOpacity = parseInt(hexColor.substring(7, 9), 16) / 255; + } + const newColor = { - colors: [hexColor], - opacity: colorChoice.opacity, // Don't change opacity + colors: [colorOnly], + opacity: newOpacity, }; - changeColor(newColor); + changeColor(newColor, { complete: true }); }; const getColorInfoFromColorResult = ( @@ -81,11 +219,45 @@ export const ColorPicker: React.FunctionComponent = ( }; const getRgbaOfCurrentColor = (): RGBColor => { - const rgbColor = tinycolor(colorChoice.colors[0]).toRgb(); - rgbColor.a = colorChoice.opacity; + const rgbColor = tinycolor(props.currentColor.colors[0]).toRgb(); + rgbColor.a = props.currentColor.opacity; return rgbColor; }; + const handleEyedropperClick = async (): Promise => { + if (eyedropperActive) { + return; + } + + const constructor = getEyeDropperConstructor(); + if (!constructor) { + return; + } + + setEyedropperActive(true); + props.onEyedropperActiveChange?.(true); + setEyedropperBackdropTransparent(backdropSelector, true); + const restorePageScaling = setPageScalingDisabled(true); + try { + const result = await new constructor().open(); + if (result?.sRGBHex) { + changeColor( + getColorInfoFromSpecialNameOrColorString(result.sRGBHex), + { complete: true }, + ); + } + } catch { + // The user can cancel (e.g. Escape), which rejects the promise. + } finally { + restorePageScaling(); + setEyedropperBackdropTransparent(backdropSelector, false); + if (mountedRef.current) { + setEyedropperActive(false); + props.onEyedropperActiveChange?.(false); + } + } + }; + const getColorSwatches = () => ( {props.swatchColors @@ -121,30 +293,53 @@ export const ColorPicker: React.FunctionComponent = ( overflow-x: hidden; `} > + {/* Keep the picker mounted during drags; remounting here breaks slider pointer capture. */}
+ {hasNativeEyedropper && ( + + + + )} diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx index 14f6181972cc..aecc3ef4f3c7 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx @@ -1,7 +1,7 @@ -import { css } from "@emotion/react"; +import { css, Global } from "@emotion/react"; import * as React from "react"; import * as ReactDOM from "react-dom"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getWorkspaceBundleExports } from "../../bookEdit/js/workspaceFrames"; import { ThemeProvider, StyledEngineProvider } from "@mui/material/styles"; import { lightTheme } from "../../bloomMaterialUITheme"; @@ -27,6 +27,46 @@ import { DialogOkButton, } from "../BloomDialog/commonDialogComponents"; +// These helpers don't depend on component state/props; keeping them outside avoids hook-deps issues. +const willSwatchColorBeFilteredOut = ( + color: IColorInfo, + transparency?: boolean, + noGradientSwatches?: boolean, +): boolean => { + if (!transparency && color.opacity !== 1) { + return true; + } + if (noGradientSwatches && color.colors.length > 1) { + return true; + } + return false; +}; + +const colorCompareFunc = + (colorA: IColorInfo) => + (colorB: IColorInfo): boolean => { + if (colorB.colors.length !== colorA.colors.length) { + return false; // One is a gradient and the other is not. + } + if (colorA.colors.length > 1) { + // In the case of both being gradients, check the second color first. + const gradientAColor2 = tinycolor(colorA.colors[1]); + const gradientBColor2 = tinycolor(colorB.colors[1]); + if (gradientAColor2.toHex() !== gradientBColor2.toHex()) { + return false; + } + } + const gradientAColor1 = tinycolor(colorA.colors[0]); + const gradientBColor1 = tinycolor(colorB.colors[0]); + return ( + gradientAColor1.toHex() === gradientBColor1.toHex() && + colorA.opacity === colorB.opacity + ); + }; + +const isColorInThisArray = (color: IColorInfo, arrayOfColors: IColorInfo[]) => + !!arrayOfColors.find(colorCompareFunc(color)); + export interface IColorPickerDialogProps { open?: boolean; close?: (result: DialogResult) => void; @@ -37,6 +77,7 @@ export interface IColorPickerDialogProps { palette: BloomPalette; isForCanvasElement?: boolean; onChange: (color: IColorInfo) => void; + onChangeComplete?: (color: IColorInfo) => void; onDefaultClick?: () => void; onInputFocus: (input: HTMLElement) => void; includeDefault?: boolean; @@ -51,6 +92,13 @@ const ColorPickerDialog: React.FC = (props) => { props.open === undefined ? true : props.open, ); const [currentColor, setCurrentColor] = useState(props.initialColor); + const [eyedropperActive, setEyedropperActive] = useState(false); + + // Use a content-based key so we don't treat a new object reference with the + // same values as a meaningful change (important for callers that compute + // initialColor inline). + const initialColorKey = + props.initialColor.colors.join("|") + "|" + props.initialColor.opacity; const [swatchColorArray, setSwatchColorArray] = useState( getDefaultColorsFromPalette(props.palette), @@ -59,19 +107,105 @@ const ColorPickerDialog: React.FC = (props) => { externalSetOpen = setOpen; const dlgRef = useRef(null); - function addCustomColors(endpoint: string): void { - get(endpoint, (result) => { - const jsonArray = result.data; - if (!jsonArray.map) { - return; // this means the conversion string -> JSON didn't work. Bad JSON? - } - const customColors = convertJsonColorArrayToColorInfos(jsonArray); - addNewColorsToArrayIfNecessary(customColors); - }); - } + // We come to here on opening to add colors already in the book and we come here on closing to see + // if our new current color needs to be added to our array. + // Enhance: What if the number of distinct colors already used in the book that we get back, plus the number + // of other default colors is more than will fit in our array (current 21)? When we get colors from the book, + // we should maybe start with the current page, to give them a better chance of being included in the picker. + const addNewColorsToArrayIfNecessary = useCallback( + (newColors: IColorInfo[]) => { + // Every time we reference the current swatchColorArray inside + // this setter, we must use previousSwatchColorArray. + // Otherwise, we add to a stale array. + setSwatchColorArray((previousSwatchColorArray) => { + const newColorsAdded: IColorInfo[] = []; + const lengthBefore = previousSwatchColorArray.length; + let numberToDelete = 0; + // CustomColorPicker is going to filter these colors out anyway. + let numberToSkip = previousSwatchColorArray.filter((color) => + willSwatchColorBeFilteredOut( + color, + props.transparency, + props.noGradientSwatches, + ), + ).length; + newColors.forEach((newColor) => { + if ( + isColorInThisArray(newColor, previousSwatchColorArray) + ) { + return; // This one is already in our array of swatch colors + } + if (isColorInThisArray(newColor, newColorsAdded)) { + return; // We don't need to add the same color more than once! + } + // At first I wanted to do this filtering outside the loop, but some of them might be pre-filtered + // by the above two conditions. + if ( + willSwatchColorBeFilteredOut( + newColor, + props.transparency, + props.noGradientSwatches, + ) + ) { + numberToSkip++; + } + if ( + lengthBefore + newColorsAdded.length + 1 > + MAX_SWATCHES + numberToSkip + ) { + numberToDelete++; + } + newColorsAdded.unshift(newColor); // add newColor to the beginning of the array. + }); + const newSwatchColorArray = previousSwatchColorArray.slice(); // Get a new array copy of the old (a different reference) + if (numberToDelete > 0) { + // Remove 'numberToDelete' swatches from oldest custom swatches + const defaultNumber = getDefaultColorsFromPalette( + props.palette, + ).length; + const indexToRemove = + previousSwatchColorArray.length - + defaultNumber - + numberToDelete; + if (indexToRemove >= 0) { + newSwatchColorArray.splice( + indexToRemove, + numberToDelete, + ); + } else { + const excess = indexToRemove * -1; // index went negative; excess is absolute value + newSwatchColorArray.splice(0, numberToDelete - excess); + newColorsAdded.splice( + newColorsAdded.length - excess, + excess, + ); + } + } + const result = newColorsAdded.concat(newSwatchColorArray); + //console.log(result); + return result; + }); + }, + [props.noGradientSwatches, props.palette, props.transparency], + ); + // When the dialog is (re)opened, initialize swatches and currentColor. + // We depend on initialColorKey rather than props.initialColor to avoid resetting the UI + // if a caller passes a new object reference with the same color values on each render. useEffect(() => { if (props.open || open) { + const addCustomColors = (endpoint: string): void => { + get(endpoint, (result) => { + const jsonArray = result.data; + if (!jsonArray.map) { + return; // this means the conversion string -> JSON didn't work. Bad JSON? + } + const customColors = + convertJsonColorArrayToColorInfos(jsonArray); + addNewColorsToArrayIfNecessary(customColors); + }); + }; + setSwatchColorArray(getDefaultColorsFromPalette(props.palette)); addCustomColors( `settings/getCustomPaletteColors?palette=${props.palette}`, @@ -85,13 +219,28 @@ const ColorPickerDialog: React.FC = (props) => { addCustomColors("editView/getColorsUsedInBookCanvasElements"); setCurrentColor(props.initialColor); } - }, [open, props.open]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + open, + props.open, + props.palette, + props.isForCanvasElement, + initialColorKey, + addNewColorsToArrayIfNecessary, + ]); + + // Keep the focus callback current even though we attach DOM listeners only once. + const onInputFocusRef = useRef(props.onInputFocus); + useEffect(() => { + onInputFocusRef.current = props.onInputFocus; + }, [props.onInputFocus]); const focusFunc = (ev: FocusEvent) => { - props.onInputFocus(ev.currentTarget as HTMLElement); + onInputFocusRef.current(ev.currentTarget as HTMLElement); }; - React.useEffect(() => { + // Install focus listeners on inputs so the client can restore focus when canvas updates steal it. + useEffect(() => { const parent = dlgRef.current; if (!parent) { return; @@ -128,7 +277,7 @@ const ColorPickerDialog: React.FC = (props) => { input.removeEventListener("focus", focusFunc), ); }; - }, [dlgRef.current]); + }, []); const convertJsonColorArrayToColorInfos = ( jsonArray: IColorInfo[], @@ -157,6 +306,7 @@ const ColorPickerDialog: React.FC = (props) => { setOpen(false); if (result === DialogResult.Cancel) { props.onChange(props.initialColor); + props.onChangeComplete?.(props.initialColor); setCurrentColor(props.initialColor); } else { if (!isColorInCurrentSwatchColorArray(currentColor)) { @@ -173,119 +323,51 @@ const ColorPickerDialog: React.FC = (props) => { } }; - // We come to here on opening to add colors already in the book and we come here on closing to see - // if our new current color needs to be added to our array. - // Enhance: What if the number of distinct colors already used in the book that we get back, plus the number - // of other default colors is more than will fit in our array (current 21)? When we get colors from the book, - // we should maybe start with the current page, to give them a better chance of being included in the picker. - const addNewColorsToArrayIfNecessary = (newColors: IColorInfo[]) => { - // Every time we reference the current swatchColorArray inside - // this setter, we must use previousSwatchColorArray. - // Otherwise, we add to a stale array. - setSwatchColorArray((previousSwatchColorArray) => { - const newColorsAdded: IColorInfo[] = []; - const lengthBefore = previousSwatchColorArray.length; - let numberToDelete = 0; - // CustomColorPicker is going to filter these colors out anyway. - let numberToSkip = previousSwatchColorArray.filter((color) => - willSwatchColorBeFilteredOut(color), - ).length; - newColors.forEach((newColor) => { - if (isColorInThisArray(newColor, previousSwatchColorArray)) { - return; // This one is already in our array of swatch colors - } - if (isColorInThisArray(newColor, newColorsAdded)) { - return; // We don't need to add the same color more than once! - } - // At first I wanted to do this filtering outside the loop, but some of them might be pre-filtered - // by the above two conditions. - if (willSwatchColorBeFilteredOut(newColor)) { - numberToSkip++; - } - if ( - lengthBefore + newColorsAdded.length + 1 > - MAX_SWATCHES + numberToSkip - ) { - numberToDelete++; - } - newColorsAdded.unshift(newColor); // add newColor to the beginning of the array. - }); - const newSwatchColorArray = swatchColorArray.slice(); // Get a new array copy of the old (a different reference) - if (numberToDelete > 0) { - // Remove 'numberToDelete' swatches from oldest custom swatches - const defaultNumber = getDefaultColorsFromPalette( - props.palette, - ).length; - const indexToRemove = - swatchColorArray.length - defaultNumber - numberToDelete; - if (indexToRemove >= 0) { - newSwatchColorArray.splice(indexToRemove, numberToDelete); - } else { - const excess = indexToRemove * -1; // index went negative; excess is absolute value - newSwatchColorArray.splice(0, numberToDelete - excess); - newColorsAdded.splice( - newColorsAdded.length - excess, - excess, - ); - } - } - const result = newColorsAdded.concat(previousSwatchColorArray); - //console.log(result); - return result; - }); - }; - const isColorInCurrentSwatchColorArray = (color: IColorInfo): boolean => isColorInThisArray(color, swatchColorArray); - const willSwatchColorBeFilteredOut = (color: IColorInfo): boolean => { - if (!props.transparency && color.opacity !== 1) { - return true; - } - if (props.noGradientSwatches && color.colors.length > 1) { - return true; - } - return false; + const handleOnChange = (color: IColorInfo) => { + const clonedColor: IColorInfo = { + ...color, + colors: [...color.colors], + }; + setCurrentColor(clonedColor); + props.onChange(clonedColor); }; - // Use a compare function to see if the color in question matches on already in this list or not. - const isColorInThisArray = ( - color: IColorInfo, - arrayOfColors: IColorInfo[], - ): boolean => !!arrayOfColors.find(colorCompareFunc(color)); - - // Function for comparing a color with an array of colors to see if the color is already - // in the array. We pass this function to .find(). - const colorCompareFunc = - (colorA: IColorInfo) => - (colorB: IColorInfo): boolean => { - if (colorB.colors.length !== colorA.colors.length) { - return false; // One is a gradient and the other is not. - } - if (colorA.colors.length > 1) { - // In the case of both being gradients, check the second color first. - const gradientAColor2 = tinycolor(colorA.colors[1]); - const gradientBColor2 = tinycolor(colorB.colors[1]); - if (gradientAColor2.toHex() !== gradientBColor2.toHex()) { - return false; - } - } - const gradientAColor1 = tinycolor(colorA.colors[0]); - const gradientBColor1 = tinycolor(colorB.colors[0]); - return ( - gradientAColor1.toHex() === gradientBColor1.toHex() && - colorA.opacity === colorB.opacity - ); + const handleOnChangeComplete = (color: IColorInfo) => { + const clonedColor: IColorInfo = { + ...color, + colors: [...color.colors], }; - - const handleOnChange = (color: IColorInfo) => { - setCurrentColor(color); - props.onChange(color); + props.onChangeComplete?.(clonedColor); }; + const dialogOpen = props.open === undefined ? open : props.open; + + // The color picker often opens from inside another dialog. MUI renders that + // outer backdrop outside the nested dialog tree, so we suppress it at the body + // level while keeping this dialog's own invisible backdrop for outside-click handling. + useEffect(() => { + if (!dialogOpen) { + return; + } + document.body.classList.add("bloom-hide-color-picker-backdrop"); + return () => { + document.body.classList.remove("bloom-hide-color-picker-backdrop"); + }; + }, [dialogOpen]); + return ( + = (props) => { padding: 10px 14px 10px 10px; // maintain same spacing all around dialog content and between header/footer } `} - open={props.open === undefined ? open : props.open} + BackdropProps={{ + invisible: true, + }} + slotProps={{ + backdrop: { + invisible: true, + }, + }} + open={dialogOpen} ref={dlgRef} onClose={( _event, reason: "backdropClick" | "escapeKeyDown", ) => { - if (reason === "backdropClick") + if (eyedropperActive) { + return; + } + if (reason === "backdropClick") { onClose(DialogResult.OK); + return; + } if (reason === "escapeKeyDown") onClose(DialogResult.Cancel); }} @@ -324,12 +419,14 @@ const ColorPickerDialog: React.FC = (props) => { @@ -366,13 +463,7 @@ export const showColorPickerDialog = ( }; export const hideColorPickerDialog = () => { - // I'm not sure if this can be falsy, but whereas in the method above we're calling it - // immediately after we render the dialog, which sets it, this gets called long after - // when the tool is closed. Just in case it somehow gets cleared, now or in some future - // version of the code, I decided to leave in the check that CoPilot proposed. - if (externalSetOpen) { - externalSetOpen(false); - } + externalSetOpen(false); }; const doRender = ( @@ -412,12 +503,21 @@ export const showSimpleColorPickerDialog = ( props.initialColor, ), palette: props.palette, - onChange: (color: IColorInfo) => props.onChange(color.colors[0]), + onChange: (color: IColorInfo) => + props.onChange(getColorStringFromColorInfo(color)), onInputFocus: props.onInputFocus, }; showColorPickerDialog(fullProps, props.container); }; +const getColorStringFromColorInfo = (color: IColorInfo): string => { + const firstColor = color.colors[0]; + if (color.opacity === 1) { + return firstColor; + } + return getRgbaColorStringFromColorAndOpacity(firstColor, color.opacity); +}; + export interface IColorDisplayButtonProps { // This is slightly more than an initial color. The button will change color // independently of this to follow the state of the color picker dialog; @@ -428,19 +528,58 @@ export interface IColorDisplayButtonProps { transparency: boolean; width?: number; disabled?: boolean; + deferOnChangeUntilComplete?: boolean; onClose: (result: DialogResult, newColor: string) => void; + onChange?: (newColor: string) => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; palette: BloomPalette; } export const ColorDisplayButton: React.FC = ( props, ) => { + const onColorPickerVisibilityChanged = props.onColorPickerVisibilityChanged; + const deferOnChangeUntilComplete = props.deferOnChangeUntilComplete; + const onChange = props.onChange; const [dialogOpen, setDialogOpen] = useState(false); + const [colorAtDialogOpen, setColorAtDialogOpen] = useState( + props.initialColor, + ); const [currentButtonColor, setCurrentButtonColor] = useState( props.initialColor, ); const widthString = props.width ? `width: ${props.width}px;` : ""; + const initialColorInfo = React.useMemo( + () => + getColorInfoFromSpecialNameOrColorString( + dialogOpen ? colorAtDialogOpen : props.initialColor, + ), + [props.initialColor, dialogOpen, colorAtDialogOpen], + ); + + const handleDialogChange = React.useCallback( + (color: IColorInfo) => { + const newColor = getColorStringFromColorInfo(color); + setCurrentButtonColor(newColor); + if (!deferOnChangeUntilComplete && onChange) { + onChange(newColor); + } + }, + [deferOnChangeUntilComplete, onChange], + ); + + const handleDialogChangeComplete = React.useCallback( + (color: IColorInfo) => { + const newColor = getColorStringFromColorInfo(color); + setCurrentButtonColor(newColor); + if (onChange) { + onChange(newColor); + } + }, + [onChange], + ); + useEffect(() => { if (currentButtonColor !== props.initialColor) { setCurrentButtonColor(props.initialColor); @@ -452,50 +591,113 @@ export const ColorDisplayButton: React.FC = ( // other than a new props value changes it. ) // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.initialColor]); + + useEffect(() => { + return () => { + if (onColorPickerVisibilityChanged) { + onColorPickerVisibilityChanged(false); + } + }; + }, [onColorPickerVisibilityChanged]); + return (
{ - if (props.disabled) return; - setDialogOpen(true); - }} - /> + > +
+
{ + if (props.disabled) return; + if (onColorPickerVisibilityChanged) { + onColorPickerVisibilityChanged(true); + } + setColorAtDialogOpen(props.initialColor); + setDialogOpen(true); + }} + /> +
{ setDialogOpen(false); + if (onColorPickerVisibilityChanged) { + onColorPickerVisibilityChanged(false); + } + if (result === DialogResult.Cancel) { + setCurrentButtonColor(colorAtDialogOpen); + } props.onClose( result, result === DialogResult.OK ? currentButtonColor - : props.initialColor, + : colorAtDialogOpen, ); }} localizedTitle={props.localizedTitle} transparency={props.transparency} palette={props.palette} - initialColor={getColorInfoFromSpecialNameOrColorString( - props.initialColor, - )} + initialColor={initialColorInfo} onInputFocus={() => {}} - onChange={(color: IColorInfo) => - setCurrentButtonColor(color.colors[0]) + onChange={handleDialogChange} + onChangeComplete={ + deferOnChangeUntilComplete + ? handleDialogChangeComplete + : undefined } />
diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts new file mode 100644 index 000000000000..e7fa30666fe6 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts @@ -0,0 +1,124 @@ +import { test, expect } from "../../component-tester/playwrightTest"; +import { setTestComponent } from "../../component-tester/setTestComponent"; + +test.describe("ColorDisplayButton + ColorPickerDialog", () => { + test("single swatch click updates hex input in dialog", async ({ + page, + }) => { + await page.route( + "**/settings/getCustomPaletteColors?palette=*", + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: "[]", + }), + ); + + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + {}, + ); + + await page.getByTestId("color-display-button-swatch").click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + const hexInput = dialog.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111"); + + await dialog.locator(".swatch-row .color-swatch").first().click(); + await expect(hexInput).not.toHaveValue("#111111"); + }); + + test("transparent selection keeps transparency background visible", async ({ + page, + }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + { + initialColor: "transparent", + transparency: true, + }, + ); + + const transparencyBackground = page.getByTestId( + "color-display-button-transparency-background", + ); + await expect(transparencyBackground).toBeVisible({ timeout: 5000 }); + + const backgroundImage = await transparencyBackground.evaluate( + (element) => getComputedStyle(element).backgroundImage, + ); + expect(backgroundImage).not.toBe("none"); + + await expect(page.getByTestId("color-display-button-swatch")).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + }); + + test("deferred change waits until drag completes and cancel restores", async ({ + page, + }) => { + await page.route( + "**/settings/getCustomPaletteColors?palette=*", + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: "[]", + }), + ); + + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + { + initialColor: "#00AA00", + deferOnChangeUntilComplete: true, + }, + ); + + await page.getByTestId("color-display-button-swatch").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.getByTestId("change-count")).toHaveText("0"); + + const hue = page.locator(".hue-horizontal"); + const box = await hue.boundingBox(); + expect(box).not.toBeNull(); + + await page.mouse.move(box!.x + 5, box!.y + box!.height / 2); + await page.mouse.down(); + await page.mouse.move( + box!.x + box!.width * 0.65, + box!.y + box!.height / 2, + { + steps: 8, + }, + ); + + await expect(page.getByTestId("change-count")).toHaveText("0"); + + await page.mouse.up(); + + await expect(page.getByTestId("change-count")).toHaveText("1"); + await expect(page.getByTestId("last-changed-color")).not.toHaveText( + "#00AA00", + ); + + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByTestId("change-count")).toHaveText("2"); + await expect(page.getByTestId("last-changed-color")).toHaveText( + "#00aa00", + ); + await expect(page.getByTestId("close-result")).toHaveText("cancel"); + }); +}); diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx new file mode 100644 index 000000000000..ab8e9bd17f52 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { useState } from "react"; +import { ColorDisplayButton, DialogResult } from "../colorPickerDialog"; +import { BloomPalette } from "../bloomPalette"; + +export const ColorDisplayButtonTestHarness: React.FunctionComponent<{ + initialColor?: string; + transparency?: boolean; + deferOnChangeUntilComplete?: boolean; +}> = (props) => { + const [changeCount, setChangeCount] = useState(0); + const [lastChangedColor, setLastChangedColor] = useState(""); + const [closeResult, setCloseResult] = useState(""); + + return ( +
+
{changeCount}
+
{lastChangedColor}
+
{closeResult}
+ { + setChangeCount((previousCount) => previousCount + 1); + setLastChangedColor(newColor); + }} + onClose={(result: DialogResult, _newColor: string) => { + setCloseResult( + result === DialogResult.OK ? "ok" : "cancel", + ); + }} + /> +
+ ); +}; diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts new file mode 100644 index 000000000000..5af52014ad76 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts @@ -0,0 +1,105 @@ +import { test, expect } from "../../component-tester/playwrightTest"; +import { setTestComponent } from "../../component-tester/setTestComponent"; + +test.describe("ColorPicker", () => { + test("single swatch click updates hex input", async ({ page }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const hexInput = page.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111FF"); + + await page.locator(".swatch-row .color-swatch").first().click(); + await expect(hexInput).toHaveValue("#AA0000FF"); + }); + + test("eyedropper (native) updates hex input", async ({ page }) => { + await page.addInitScript(() => { + ( + window as unknown as Window & { + EyeDropper: { + new (): { open: () => Promise<{ sRGBHex: string }> }; + }; + } + ).EyeDropper = class { + public async open(): Promise<{ sRGBHex: string }> { + return { sRGBHex: "#00AA00" }; + } + }; + }); + + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const hexInput = page.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111FF"); + + await page.locator('button[title="Sample Color"]').click(); + await expect(hexInput).toHaveValue("#00AA00FF"); + }); + + test("external currentColor change updates hex input", async ({ page }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const hexInput = page.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111FF"); + + await page.getByTestId("simulate-external-color").click(); + await expect(hexInput).toHaveValue("#123456FF"); + }); + + test("hue slider supports continuous drag updates", async ({ page }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const swatches = page.locator(".swatch-row .color-swatch"); + await swatches.nth(1).click(); + + const hexInput = page.locator('input[type="text"]'); + const beforeDrag = await hexInput.inputValue(); + + const hue = page.locator(".hue-horizontal"); + const box = await hue.boundingBox(); + expect(box).not.toBeNull(); + + await page.mouse.move(box!.x + 5, box!.y + box!.height / 2); + await page.mouse.down(); + await page.mouse.move( + box!.x + box!.width * 0.35, + box!.y + box!.height / 2, + { + steps: 8, + }, + ); + const duringDrag = await hexInput.inputValue(); + await page.mouse.move( + box!.x + box!.width * 0.7, + box!.y + box!.height / 2, + { + steps: 8, + }, + ); + await page.mouse.up(); + const afterDrag = await hexInput.inputValue(); + + expect(beforeDrag).not.toEqual(duringDrag); + expect(duringDrag).not.toEqual(afterDrag); + }); +}); diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerManualHarness.tsx b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerManualHarness.tsx new file mode 100644 index 000000000000..1c1514f9a0b0 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerManualHarness.tsx @@ -0,0 +1,41 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { useState } from "react"; +import { ColorPicker } from "../colorPicker"; +import { IColorInfo } from "../colorSwatch"; + +export const ColorPickerManualHarness: React.FunctionComponent = () => { + const [currentColor, setCurrentColor] = useState({ + colors: ["#E48C84"], + opacity: 1, + }); + + const swatches: IColorInfo[] = [ + { colors: ["#E48C84"], opacity: 1 }, + { colors: ["#B58B4F"], opacity: 1 }, + { colors: ["#7E5A3C"], opacity: 1 }, + { colors: ["#F0E5D8"], opacity: 1 }, + { colors: ["#D9A6A0"], opacity: 1 }, + { colors: ["#8C6A5A"], opacity: 1 }, + { colors: ["#6D7A7B"], opacity: 1 }, + { colors: ["#F0D36E"], opacity: 1 }, + { colors: ["#85B2C2"], opacity: 1 }, + ]; + + return ( +
+ { + setCurrentColor(color); + }} + /> +
+ ); +}; diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerTestHarness.tsx b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerTestHarness.tsx new file mode 100644 index 000000000000..45092d444ec4 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerTestHarness.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import { useState } from "react"; +import { ColorPicker } from "../colorPicker"; +import { IColorInfo } from "../colorSwatch"; + +export const ColorPickerTestHarness: React.FunctionComponent = () => { + const [currentColor, setCurrentColor] = useState({ + colors: ["#111111"], + opacity: 1, + }); + + const swatches: IColorInfo[] = [ + { colors: ["#AA0000"], opacity: 1 }, + { colors: ["#00AA00"], opacity: 1 }, + { colors: ["#0000AA"], opacity: 1 }, + ]; + + return ( +
+ + +
+ {currentColor.colors.join("|") + "|" + currentColor.opacity} +
+ + setCurrentColor(color)} + /> +
+ ); +}; diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/show-component.uitest.ts b/src/BloomBrowserUI/react_components/color-picking/component-tests/show-component.uitest.ts new file mode 100644 index 000000000000..0d1957b5fded --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/show-component.uitest.ts @@ -0,0 +1,58 @@ +/** + * Interactive manual testing mode using Playwright. + * This opens a visible browser with the component and keeps it open indefinitely + * so you can interact with it manually. + * + * Run with: ./show.sh + */ +import { test } from "../../component-tester/playwrightTest"; +import { setTestComponent } from "../../component-tester/setTestComponent"; + +const includeManualTests = process.env.PLAYWRIGHT_INCLUDE_MANUAL === "1"; +const manualDescribe = includeManualTests ? test.describe : test.describe.skip; + +manualDescribe("Manual Interactive Testing", () => { + test("default", async ({ page }) => { + test.setTimeout(0); + + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerManualHarness", + "ColorPickerManualHarness", + {}, + ); + + await page.waitForEvent("close"); + }); + + test("dialog", async ({ page }) => { + test.setTimeout(0); + + await page.route( + "**/settings/getCustomPaletteColors?palette=*", + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: "[]", + }), + ); + + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + {}, + ); + + await page.waitForEvent("close"); + }); + + test("with-bloom-backend", async ({ page }) => { + test.setTimeout(0); + + await page.goto("/?component=ColorSwatch"); + + await page.waitForEvent("close"); + }); +}); diff --git a/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx b/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx index 73bbdf33699d..3e3a03422b9b 100644 --- a/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx @@ -7,54 +7,92 @@ import { IColorInfo } from "./colorSwatch"; interface IHexColorInputProps { initial: IColorInfo; onChangeComplete: (newValue: string) => void; + includeOpacityChannel?: boolean; } const hashChar = "#"; +const massageColorInput = ( + color: string, + includeOpacityChannel?: boolean, +): string => { + let result = color.toUpperCase(); + result = result.replace(/[^0-9A-F]/g, ""); // eliminate any non-hex characters + result = hashChar + result; // insert hash as the first character + const maxLength = includeOpacityChannel ? 9 : 7; + if (result.length > maxLength) { + result = result.slice(0, maxLength); + } + return result; +}; + +// In general, we want our Hex Color input to reflect the first value in the 'colors' array. +// For our predefined gradients, however, we want the hex input to be empty. +// And for named colors, we need to show the hex equivalent. +const getHexColorValueFromColorInfo = ( + colorInfo: IColorInfo, + includeOpacityChannel?: boolean, +): string => { + // First, our hex value will be empty, if we're dealing with a gradient. + // The massage method below will add a hash character... + if (colorInfo.colors.length > 1) return ""; + const firstColor = colorInfo.colors[0]; + const hexColor = tinycolor(firstColor).toHexString(); + + if (!includeOpacityChannel) { + return hexColor; + } + + const alphaHex = Math.round(colorInfo.opacity * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `${hexColor}${alphaHex}`; +}; + export const HexColorInput: React.FunctionComponent = ( props, ) => { - const [currentColor, setCurrentColor] = useState(""); + const getHexValue = React.useCallback( + (colorInfo: IColorInfo): string => + massageColorInput( + getHexColorValueFromColorInfo( + colorInfo, + props.includeOpacityChannel, + ), + props.includeOpacityChannel, + ), + [props.includeOpacityChannel], + ); - // In general, we want our Hex Color input to reflect the first value in the 'colors' array. - // For our predefined gradients, however, we want the hex input to be empty. - // And for named colors, we need to show the hex equivalent. - const getHexColorValueFromColorInfo = (): string => { - // First, our hex value will be empty, if we're dealing with a gradient. - // The massage method below will add a hash character... - if (props.initial.colors.length > 1) return ""; - const firstColor = props.initial.colors[0]; - if (firstColor[0] === hashChar) return firstColor; - // In some cases we might be dealing with a color word like "black" or "white" or "transparent". - return tinycolor(firstColor).toHexString(); - }; + const [currentColor, setCurrentColor] = useState(() => + getHexValue(props.initial), + ); - const massageColorInput = (color: string): string => { - let result = color.toUpperCase(); - result = result.replace(/[^0-9A-F]/g, ""); // eliminate any non-hex characters - result = hashChar + result; // insert hash as the first character - if (result.length > 7) { - result = result.slice(0, 7); - } - return result; - }; + const initialHexValue = getHexValue(props.initial); + // Keep the displayed hex string in sync when the parent changes the color programmatically + // (e.g. swatch click, eyedropper, or external currentColor updates). useEffect(() => { - setCurrentColor(massageColorInput(getHexColorValueFromColorInfo())); - }, [props.initial.colors]); + setCurrentColor(initialHexValue); + }, [initialHexValue]); const handleInputChange: React.ChangeEventHandler = ( e, ) => { - const result = massageColorInput(e.target.value); + const result = massageColorInput( + e.target.value, + props.includeOpacityChannel, + ); setCurrentColor(result); - if (result.length === 7) { + const completeLength = props.includeOpacityChannel ? 9 : 7; + if (result.length === completeLength) { props.onChangeComplete(result); } }; const borderThickness = 2; - const controlWidth = 60; // This width handles "#DDDDDD" as the maximum width input. + const controlWidth = props.includeOpacityChannel ? 80 : 60; const inputWidth = controlWidth - 2 * borderThickness; return ( diff --git a/src/BloomBrowserUI/react_components/color-picking/show.sh b/src/BloomBrowserUI/react_components/color-picking/show.sh new file mode 100644 index 000000000000..8ec1f479cb96 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/show.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Manual testing for color-picking +# Uses Playwright with full mock support from test-helpers.ts +# Usage: ./show.sh [test-name] + +set -euo pipefail + +COMPONENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPONENT_NAME="$(basename "$COMPONENT_DIR")" + +cd "$COMPONENT_DIR/../component-tester" + +./show-component.sh "$COMPONENT_NAME" "$@" diff --git a/src/BloomBrowserUI/react_components/color-picking/test.sh b/src/BloomBrowserUI/react_components/color-picking/test.sh new file mode 100644 index 000000000000..fddac4e6631c --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/test.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Run automated UI tests for this component +set -e + +script_dir="$(cd "$(dirname "$0")" && pwd)" +cd "$script_dir/../component-tester" + +component_path="../color-picking/component-tests" + +if [ "${1:-}" = "--ui" ]; then + shift + yarn test:ui "$component_path" "$@" +else + yarn test "$component_path" "$@" +fi diff --git a/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts b/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts new file mode 100644 index 000000000000..50c427ffd3eb --- /dev/null +++ b/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts @@ -0,0 +1,50 @@ +export type ElementAttributeMap = { + [attributeName: string]: string; +}; + +export class ElementAttributeSnapshot { + private readonly attributes: ElementAttributeMap; + + private constructor(attributes: ElementAttributeMap) { + this.attributes = attributes; + } + + public static fromElement = ( + element: Element, + ): ElementAttributeSnapshot => { + const snapshot: ElementAttributeMap = {}; + for (let index = 0; index < element.attributes.length; index++) { + const attribute = element.attributes.item(index); + if (attribute) { + snapshot[attribute.name] = attribute.value; + } + } + + return new ElementAttributeSnapshot(snapshot); + }; + + public restoreToElement = (element: Element): void => { + const currentAttributeNames: string[] = []; + for (let index = 0; index < element.attributes.length; index++) { + const attribute = element.attributes.item(index); + if (attribute) { + currentAttributeNames.push(attribute.name); + } + } + + currentAttributeNames.forEach((attributeName) => { + if ( + !Object.prototype.hasOwnProperty.call( + this.attributes, + attributeName, + ) + ) { + element.removeAttribute(attributeName); + } + }); + + Object.keys(this.attributes).forEach((attributeName) => { + element.setAttribute(attributeName, this.attributes[attributeName]); + }); + }; +} diff --git a/src/BloomBrowserUI/yarn.lock b/src/BloomBrowserUI/yarn.lock index f61854045ac6..724647448633 100644 --- a/src/BloomBrowserUI/yarn.lock +++ b/src/BloomBrowserUI/yarn.lock @@ -2940,10 +2940,10 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz#1657f56326bbe0ac80eedc9f9c18fc1ddd24e107" integrity sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg== -"@sillsdev/config-r@1.0.0-alpha.18": - version "1.0.0-alpha.18" - resolved "https://registry.npmjs.org/@sillsdev/config-r/-/config-r-1.0.0-alpha.18.tgz#177178ec2bba9e2843a3edab949c6b6489f0286d" - integrity sha512-EFiyAwUTMJ4jlvXRMBsO4+Zm8Gkaur+idUB3czXADqE0zG8ZnrMug951dWv67uFLH6hZT9jhGasEsHU1G/2/qA== +"@sillsdev/config-r@1.0.0-alpha.22": + version "1.0.0-alpha.22" + resolved "https://registry.npmjs.org/@sillsdev/config-r/-/config-r-1.0.0-alpha.22.tgz#2a8bbbf2c73008a342cf1a8d0304bf0076ec1586" + integrity sha512-6tH8KuPSGKPYSb8n2Prl8VHC45ggW5Uq+GbGEtrDY4dONLcN7GHANiZrZuJBXGNzK2yScuYjby2NWmoelvbAgg== dependencies: "@textea/json-viewer" "^2.13.1" formik "^2.2.9" diff --git a/src/BloomExe/Book/AppearanceSettings.cs b/src/BloomExe/Book/AppearanceSettings.cs index df99e4bf78c8..fc19c8d8b27c 100644 --- a/src/BloomExe/Book/AppearanceSettings.cs +++ b/src/BloomExe/Book/AppearanceSettings.cs @@ -156,6 +156,8 @@ public string FirstPossiblyOffendingCssFile new CssStringVariableDef("page-split-vertical-gap", "margins"), new CssStringVariableDef("pageNumber-always-left-margin", "page-number"), new CssStringVariableDef("pageNumber-background-color", "page-number"), + new CssStringVariableDef("pageNumber-color", "page-number"), + new CssStringVariableDef("pageNumber-outline-color", "page-number"), new CssStringVariableDef("pageNumber-background-width", "page-number"), new CssStringVariableDef("pageNumber-border-radius", "page-number"), new CssStringVariableDef("pageNumber-bottom", "page-number"), diff --git a/src/BloomExe/Book/HtmlDom.cs b/src/BloomExe/Book/HtmlDom.cs index b2161906ad24..56e2793638f7 100644 --- a/src/BloomExe/Book/HtmlDom.cs +++ b/src/BloomExe/Book/HtmlDom.cs @@ -1886,6 +1886,44 @@ public static void RemoveTemplateEditingMarkup(SafeXmlElement editedPageDiv) public const string musicAttrName = "data-backgroundaudio"; public const string musicVolumeName = musicAttrName + "volume"; + private static readonly string[] kPageStylePropertiesToPersist = + { + "--page-background-color", + "--marginBox-background-color", + "--pageNumber-color", + "--pageNumber-outline-color", + "--pageNumber-background-color", + }; + + private static string GetPersistedPageStyleValue(SafeXmlElement editedPageDiv) + { + var style = editedPageDiv.GetAttribute("style"); + if (string.IsNullOrWhiteSpace(style)) + return string.Empty; + + var persistedStyleSegments = style + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(segment => segment.Trim()) + .Where(segment => !string.IsNullOrEmpty(segment)) + .Where(segment => + { + var colonIndex = segment.IndexOf(':'); + if (colonIndex <= 0) + return false; + + var propertyName = segment.Substring(0, colonIndex).Trim(); + return kPageStylePropertiesToPersist.Contains( + propertyName, + StringComparer.OrdinalIgnoreCase + ); + }) + .ToArray(); + + return persistedStyleSegments.Any() + ? string.Join("; ", persistedStyleSegments) + : string.Empty; + } + public static void ProcessPageAfterEditing( SafeXmlElement destinationPageDiv, SafeXmlElement edittedPageDiv @@ -1921,6 +1959,14 @@ SafeXmlElement edittedPageDiv //html file in a browser. destinationPageDiv.SetAttribute("lang", edittedPageDiv.GetAttribute("lang")); + // Save only the page color custom properties we manage in Page Settings. + // If all are missing, remove any previously-saved page-level custom properties. + var style = GetPersistedPageStyleValue(edittedPageDiv); + if (string.IsNullOrEmpty(style)) + destinationPageDiv.RemoveAttribute("style"); + else + destinationPageDiv.SetAttribute("style", style); + // Copy the two background audio attributes which can be set using the music toolbox. // Ensuring that volume is missing unless the main attribute is non-empty is // currently redundant, everything should work if we just copied all attributes. diff --git a/src/BloomExe/Edit/EditingView.cs b/src/BloomExe/Edit/EditingView.cs index 40453ecc44f4..ab826e6b8366 100644 --- a/src/BloomExe/Edit/EditingView.cs +++ b/src/BloomExe/Edit/EditingView.cs @@ -1712,10 +1712,10 @@ public void SaveAndOpenBookSettingsDialog() _model.SaveThen( () => { - // Open the book settings dialog to the context-specific group. - var groupIndex = _model.CurrentPage.IsCoverPage ? 0 : 1; + // Open the book settings dialog to the context-specific page. + var pageKey = _model.CurrentPage.IsCoverPage ? "cover" : "contentPages"; RunJavascriptAsync( - $"workspaceBundle.showEditViewBookSettingsDialog({groupIndex});" + $"workspaceBundle.showEditViewBookSettingsDialog('{pageKey}');" ); return _model.CurrentPage.Id; }, @@ -1723,6 +1723,18 @@ public void SaveAndOpenBookSettingsDialog() ); } + public void SaveAndOpenPageSettingsDialog() + { + _model.SaveThen( + () => + { + RunJavascriptAsync("workspaceBundle.showEditViewPageSettingsDialog();"); + return _model.CurrentPage.Id; + }, + () => { } // wrong state, do nothing + ); + } + public async Task AddImageFromUrlAsync(string desiredFileNameWithoutExtension, string url) { using (var client = new System.Net.Http.HttpClient()) diff --git a/src/BloomExe/web/controllers/EditingViewApi.cs b/src/BloomExe/web/controllers/EditingViewApi.cs index 489ffc21e0f2..0efef4ff2f86 100644 --- a/src/BloomExe/web/controllers/EditingViewApi.cs +++ b/src/BloomExe/web/controllers/EditingViewApi.cs @@ -131,6 +131,11 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) HandleShowBookSettingsDialog, true ); + apiHandler.RegisterEndpointHandler( + "editView/showPageSettingsDialog", + HandleShowPageSettingsDialog, + true + ); apiHandler.RegisterEndpointHandler( "editView/toggleCustomPageLayout", HandleToggleCustomCover, @@ -205,6 +210,12 @@ private void HandleShowBookSettingsDialog(ApiRequest request) View.SaveAndOpenBookSettingsDialog(); } + private void HandleShowPageSettingsDialog(ApiRequest request) + { + request.PostSucceeded(); + View.SaveAndOpenPageSettingsDialog(); + } + /// /// This one is for the snapping function on dragging origami splitters. /// diff --git a/src/BloomTests/Book/AppearanceSettingsTests.cs b/src/BloomTests/Book/AppearanceSettingsTests.cs index ca37c2f3d118..ca337cb213b4 100644 --- a/src/BloomTests/Book/AppearanceSettingsTests.cs +++ b/src/BloomTests/Book/AppearanceSettingsTests.cs @@ -289,6 +289,25 @@ public void ToCss_ContainsSettingsFromJson() Assert.That(fromSettings, Does.Contain("--cover-languageName-show: none;")); } + [Test] + public void ToCss_ContainsPageNumberColorOverridesFromJson() + { + var settings = new AppearanceSettings(); + settings.UpdateFromJson( + @" +{ + ""cssThemeName"": ""default"", + ""pageNumber-color"": ""#123456"", + ""pageNumber-outline-color"": ""#FFFFFF"" +}" + ); + + var css = settings.ToCss(); + + Assert.That(css, Does.Contain("--pageNumber-color: #123456;")); + Assert.That(css, Does.Contain("--pageNumber-outline-color: #FFFFFF;")); + } + [TestCase(@"""pageNumber-position"": ""automatic""", true)] [TestCase(@"""pageNumber-position"": ""left""", true)] [TestCase(@"""pageNumber-position"": ""center""", true)] diff --git a/src/content/appearanceThemes/appearance-theme-default.css b/src/content/appearanceThemes/appearance-theme-default.css index 532dbed270cb..21f99b589659 100644 --- a/src/content/appearanceThemes/appearance-theme-default.css +++ b/src/content/appearanceThemes/appearance-theme-default.css @@ -39,6 +39,9 @@ --pageNumber-background-width: unset; /* for when we need to have a colored background, e.g. a circle */ /* background-color: value in .numberedPage:after to display the page number */ --pageNumber-background-color: transparent; + /* color: value in .numberedPage:after to display the page number */ + --pageNumber-color: black; + --pageNumber-outline-color: transparent; /* border-radius: value in .numberedPage:after to display the page number */ --pageNumber-border-radius: 0px; /* left: value in .numberedPage.side-left:after to display the page number */ diff --git a/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css b/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css index 36d7d8f3cf4c..aa769b7c0a0d 100644 --- a/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css +++ b/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css @@ -26,16 +26,16 @@ .numberedPage:where([class*="Device"]:not(.bloom-interactive-page)) { --topLevel-text-padding: 0.5em; } - [class*="Device"].numberedPage:not(.bloom-interactive-page) { --pageNumber-extra-height: 0mm !important; /* we put the page number on top of the image so we don't need a margin boost */ + --pageNumber-background-color: #ffffff; /* I'm not clear why this is white, but all I did in this change is to move it so that it can be overridden by page settings */ } [class*="Device"].numberedPage:not(.bloom-interactive-page)::after { --pageNumber-bottom: var(--page-margin-bottom); --pageNumber-top: unset; --pageNumber-font-size: 11pt; --pageNumber-border-radius: 50%; - --pageNumber-background-color: #ffffff; + --pageNumber-background-width: 33px; --pageNumber-always-left-margin: var(--page-margin-left); --pageNumber-right-margin: deliberately-invalid; /* prevents right being set at all. unset does not work. Prevent centering for this layout */ diff --git a/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css b/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css index 2d96796458b6..8828c2daceca 100644 --- a/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css +++ b/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css @@ -17,8 +17,8 @@ Note that hiding the page numbers is done by a setting in appearance.json, not h --page-horizontalSplit-height: 0mm; } -/* The section below controls the page number and the white circle around it. */ -.Device16x9Landscape.numberedPage { +.numberedPage { + --pageNumber-background-color: #ffffff; /* I'm not clear why this is white, but all I did in this change is to move it so that it can be overridden by page settings */ --pageNumber-extra-height: 0mm !important; /* we put the page number on top of the image so we don't need a margin boost */ } .Device16x9Portrait.numberedPage { @@ -41,7 +41,7 @@ Note that hiding the page numbers is done by a setting in appearance.json, not h --pageNumber-font-size: 11pt; border-radius: 50%; - --pageNumber-background-color: #ffffff; + --pageNumber-background-width: 33px; --pageNumber-always-left-margin: var(--page-margin-left); --pageNumber-right-margin: deliberately-invalid; /* prevents right being set at all. unset does not work. Prevent centering for this layout */ diff --git a/src/content/bookLayout/pageNumbers.less b/src/content/bookLayout/pageNumbers.less index 254a9ca3a3e6..d0036cdf84fa 100644 --- a/src/content/bookLayout/pageNumbers.less +++ b/src/content/bookLayout/pageNumbers.less @@ -7,6 +7,7 @@ // themes can override this as needed. If you have reasonable margins, you don't need to add anything to fit in a pageNumber --pageNumber-extra-height: 0mm; // must have units } + .numberedPage { &:after { content: attr(data-page-number); @@ -22,6 +23,10 @@ bottom: var(--pageNumber-bottom); top: var(--pageNumber-top); background-color: var(--pageNumber-background-color); + color: var(--pageNumber-color); + -webkit-text-stroke: 1px var(--pageNumber-outline-color); + paint-order: stroke fill; + border-radius: var(--pageNumber-border-radius); z-index: 1000; // These are needed to get the number centered in a circle. They have diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000000..fb57ccd13afb --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + From 476a196824355b845f63e7bc50cbaec70a4110ee Mon Sep 17 00:00:00 2001 From: Hatton Date: Fri, 27 Mar 2026 16:57:52 -0600 Subject: [PATCH 02/65] simplify --- src/BloomBrowserUI/bookEdit/js/origami.ts | 78 ++++++++----------- .../bookEdit/js/pageSettingsButtonIcon.svg | 3 + .../CollectionsTabBookPane.tsx | 1 + src/BloomBrowserUI/custom.d.ts | 5 ++ 4 files changed, 41 insertions(+), 46 deletions(-) create mode 100644 src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg diff --git a/src/BloomBrowserUI/bookEdit/js/origami.ts b/src/BloomBrowserUI/bookEdit/js/origami.ts index b7115572a565..4d0b8d3ab506 100644 --- a/src/BloomBrowserUI/bookEdit/js/origami.ts +++ b/src/BloomBrowserUI/bookEdit/js/origami.ts @@ -6,6 +6,8 @@ import { post, postThatMightNavigate } from "../../utils/bloomApi"; import { theOneCanvasElementManager } from "./CanvasElementManager"; import { getFeatureStatusAsync } from "../../react_components/featureStatus"; import $ from "jquery"; +import "../../lib/jquery.i18n.custom"; +import pageSettingsButtonIconSvg from "./pageSettingsButtonIcon.svg?raw"; import { splitPane } from "../../lib/split-pane/split-pane"; import { kCanvasToolId } from "../toolbox/toolIds"; @@ -68,44 +70,13 @@ export function setupOrigami() { $("#myonoffswitch").prop("checked", true); } - const localizableElements = $( - ".customPage, .above-page-control-container", - ).find("*[data-i18n]"); - // In some dev/runtime paths the jQuery localize plugin is not loaded. - try { - if (typeof localizableElements.localize === "function") { - localizableElements.localize(); - } - } catch (error) { - console.warn( - "Origami localization failed; continuing with default labels.", - error, - ); - } - - ensurePageSettingsButtonHasIcon(); + $(".customPage, .above-page-control-container") + .find("*[data-i18n]") + .localize(); }); }); } -function ensurePageSettingsButtonHasIcon() { - $(".page-settings-button").each((_index, element) => { - const button = $(element); - const labelText = $.trim(button.text()) || "Page Settings"; - button.empty(); - button.append($(getPageSettingsButtonIconHtml())); - button.append( - $("").text( - labelText, - ), - ); - }); -} - -function getPageSettingsButtonIconHtml(): string { - return ``; -} - export function cleanupOrigami() { // Otherwise, we get a new one each time the page is loaded $(".split-pane-resize-shim").remove(); @@ -394,16 +365,15 @@ function getAbovePageControlContainer(showOrigamiControls: boolean): JQuery { if (!showOrigamiControls) { return $( - `
\ -${getPageSettingsButtonHtml()}\ -
`, - ); + "
", + ).append(createPageSettingsButton()); } - return $( - `\ -
\ -${getPageSettingsButtonHtml()}\ + return $("
") + .append(createPageSettingsButton()) + .append( + $( + `\
\
Change Layout
\
\ @@ -413,13 +383,29 @@ ${getPageSettingsButtonHtml()}\ \ \
\ -
\
`, - ); + ), + ); } -function getPageSettingsButtonHtml(): string { - return ``; +function createPageSettingsButton(): JQuery { + return $( + "", + ) + .append(createPageSettingsButtonIcon()) + .append( + $( + "Page Settings", + ), + ); +} + +function createPageSettingsButtonIcon(): SVGSVGElement { + const iconDocument = new DOMParser().parseFromString( + pageSettingsButtonIconSvg, + "image/svg+xml", + ); + return iconDocument.documentElement as unknown as SVGSVGElement; } function pageSettingsButtonClickHandler(e: Event) { diff --git a/src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg b/src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg new file mode 100644 index 000000000000..a54556aba9c8 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg @@ -0,0 +1,3 @@ + diff --git a/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx b/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx index c7a424a82080..728ac68ae30f 100644 --- a/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx +++ b/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx @@ -294,6 +294,7 @@ export const CollectionsTabBookPane: React.FunctionComponent<{ padding: 10px; background-color: ${kDarkestBackground}; `} + {...props} // allows defining more css rules from container >
Date: Fri, 27 Mar 2026 17:36:55 -0600 Subject: [PATCH 03/65] UseEffect reduction from https://github.com/softaworks/agent-toolkit --- .github/skills/react-useeffect/README.md | 320 ++++++++++++++++++ .github/skills/react-useeffect/SKILL.md | 53 +++ .../skills/react-useeffect/alternatives.md | 258 ++++++++++++++ .../skills/react-useeffect/anti-patterns.md | 290 ++++++++++++++++ src/BloomBrowserUI/AGENTS.md | 33 +- .../BookAndPageSettingsDialog.tsx | 66 ++-- .../collection/CollectionSettingsDialog.tsx | 21 +- .../color-picking/colorPickerDialog.tsx | 20 +- 8 files changed, 967 insertions(+), 94 deletions(-) create mode 100644 .github/skills/react-useeffect/README.md create mode 100644 .github/skills/react-useeffect/SKILL.md create mode 100644 .github/skills/react-useeffect/alternatives.md create mode 100644 .github/skills/react-useeffect/anti-patterns.md diff --git a/.github/skills/react-useeffect/README.md b/.github/skills/react-useeffect/README.md new file mode 100644 index 000000000000..490ad4bae14f --- /dev/null +++ b/.github/skills/react-useeffect/README.md @@ -0,0 +1,320 @@ +# React useEffect Best Practices + +A comprehensive guide teaching when to use `useEffect` in React, and more importantly, when NOT to use it. This skill is based on official React documentation and provides practical alternatives to common useEffect anti-patterns. + +## Purpose + +Effects are an **escape hatch** from React's reactive paradigm. They let you synchronize with external systems like browser APIs, third-party widgets, or network requests. However, many developers overuse Effects for tasks that React handles better through other means. + +This skill helps you: +- Identify when you truly need an Effect vs. when you don't +- Recognize common anti-patterns and their fixes +- Apply better alternatives like `useMemo`, `key` prop, and event handlers +- Write Effects that are clean, maintainable, and free from race conditions + +## When to Use This Skill + +Use this skill when you're: +- Writing or reviewing `useEffect` code +- Using `useState` to store derived values +- Implementing data fetching or subscriptions +- Synchronizing state between components +- Facing bugs with stale data or race conditions +- Wondering if your Effect is necessary + +**Trigger phrases:** +- "Should I use useEffect for this?" +- "How do I fix this useEffect?" +- "My Effect is causing too many re-renders" +- "Data fetching with useEffect" +- "Reset state when props change" +- "Derived state from props" + +## How It Works + +This skill provides guidance through three key resources: + +1. **Quick Reference Table** - Fast lookup for common scenarios with DO/DON'T patterns +2. **Decision Tree** - Visual flowchart to determine the right approach +3. **Detailed Anti-Patterns** - 9 common mistakes with explanations and fixes +4. **Better Alternatives** - 8 proven patterns to replace unnecessary Effects + +The skill teaches you to ask the right questions: +- Is there an external system involved? +- Am I responding to a user event or component appearance? +- Can this value be calculated during render? +- Do I need to reset state when a prop changes? + +## Key Features + +### 1. Quick Reference Guide + +Visual table showing the DO/DON'T for common scenarios: +- Derived state from props/state +- Expensive calculations +- Resetting state on prop change +- User event responses +- Notifying parent components +- Data fetching + +### 2. Decision Tree + +Clear flowchart that guides you from "Need to respond to something?" to the correct solution: +- User interaction → Event handler +- Component appeared → Effect (for external sync/analytics) +- Derived value needed → Calculate during render (+ useMemo if expensive) +- Reset state on prop change → Key prop + +### 3. Anti-Pattern Recognition + +Detailed examples of 9 common mistakes: +1. Redundant state for derived values +2. Filtering/transforming data in Effect +3. Resetting state on prop change +4. Event-specific logic in Effect +5. Chains of Effects +6. Notifying parent via Effect +7. Passing data up to parent +8. Fetching without cleanup (race conditions) +9. App initialization in Effect + +Each anti-pattern includes: +- Bad example with explanation +- Good example with fix +- Why the anti-pattern is problematic + +### 4. Better Alternatives + +8 proven patterns to replace unnecessary Effects: +1. Calculate during render for derived state +2. `useMemo` for expensive calculations +3. `key` prop to reset state +4. Store ID instead of object for stable references +5. Event handlers for user actions +6. `useSyncExternalStore` for external stores +7. Lifting state up for shared state +8. Custom hooks for data fetching with cleanup + +## Usage Examples + +### Example 1: Derived State + +**Bad - Unnecessary Effect:** +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); +} +``` + +**Good - Calculate during render:** +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const fullName = firstName + ' ' + lastName; // Just compute it +} +``` + +### Example 2: Resetting State + +**Bad - Effect to reset:** +```tsx +function ProfilePage({ userId }) { + const [comment, setComment] = useState(''); + + useEffect(() => { + setComment(''); + }, [userId]); +} +``` + +**Good - Key prop:** +```tsx +function ProfilePage({ userId }) { + return ; +} + +function Profile({ userId }) { + const [comment, setComment] = useState(''); // Resets automatically +} +``` + +### Example 3: Data Fetching with Cleanup + +**Bad - Race condition:** +```tsx +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + fetchResults(query).then(json => { + setResults(json); // "hello" response may arrive after "hell" + }); + }, [query]); +} +``` + +**Good - Cleanup flag:** +```tsx +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + let ignore = false; + + fetchResults(query).then(json => { + if (!ignore) setResults(json); + }); + + return () => { ignore = true; }; + }, [query]); +} +``` + +### Example 4: Event Handler Instead of Effect + +**Bad - Effect watching state:** +```tsx +function ProductPage({ product, addToCart }) { + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name}!`); + } + }, [product]); + + function handleBuyClick() { + addToCart(product); + } +} +``` + +**Good - Handle in event:** +```tsx +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + } +} +``` + +## When You DO Need Effects + +Effects are appropriate for: + +- **Synchronizing with external systems** - Browser APIs, third-party widgets, non-React code +- **Subscriptions** - WebSocket connections, global event listeners (prefer `useSyncExternalStore`) +- **Analytics/logging** - Code that needs to run because the component displayed +- **Data fetching** - With proper cleanup (or use your framework's built-in mechanism) + +## When You DON'T Need Effects + +Avoid Effects for: + +1. **Transforming data for rendering** - Calculate at the top level instead +2. **Handling user events** - Use event handlers where you know exactly what happened +3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` +4. **Chaining state updates** - Calculate all next state in the event handler +5. **Notifying parent components** - Call the callback in the same event handler +6. **Resetting state** - Use the `key` prop to create a fresh component instance + +## Best Practices + +### 1. Start Without an Effect + +Before adding an Effect, ask: "Is there an external system involved?" If no, you probably don't need an Effect. + +### 2. Prefer Derived State + +If you can calculate a value from props or state, don't store it in state with an Effect updating it. + +### 3. Use the Right Tool + +- Expensive calculation → `useMemo` +- User interaction → Event handler +- Reset on prop change → `key` prop +- External subscription → `useSyncExternalStore` +- Shared state → Lift state up + +### 4. Always Clean Up + +If your Effect subscribes, fetches, or sets timers, return a cleanup function to prevent memory leaks and race conditions. + +### 5. Avoid Effect Chains + +Multiple Effects triggering each other causes unnecessary re-renders and makes code hard to follow. Calculate everything in one place (usually an event handler). + +### 6. Test in Strict Mode + +React 18+ Strict Mode mounts components twice in development to expose missing cleanup. If your Effect breaks, you need cleanup. + +### 7. Consider Framework Solutions + +For data fetching, prefer your framework's built-in solution (Next.js, Remix) or libraries (React Query, SWR) over manual Effects. + +## Reference Files + +This skill includes three detailed reference documents: + +1. **SKILL.md** - Quick reference table and decision tree +2. **anti-patterns.md** - 9 common mistakes with detailed explanations +3. **alternatives.md** - 8 better alternatives with code examples + +## Common Pitfalls + +### Multiple Re-renders + +**Symptom:** Component re-renders many times in quick succession. + +**Cause:** Effect that sets state based on state it depends on, creating a loop. + +**Fix:** Calculate the final value in an event handler or during render. + +### Stale Data + +**Symptom:** UI shows outdated values briefly before updating. + +**Cause:** Using Effect to update derived state causes an extra render pass. + +**Fix:** Calculate derived values during render instead of in state. + +### Race Conditions + +**Symptom:** Fast typing shows results for old queries after new ones. + +**Cause:** Missing cleanup in data fetching Effect. + +**Fix:** Use cleanup flag (`ignore` variable) or AbortController. + +### Runs Twice in Development + +**Symptom:** Effect runs twice on component mount in development. + +**Cause:** React 18 Strict Mode intentionally mounts components twice to expose bugs. + +**Fix:** Add proper cleanup. If it's app initialization that shouldn't run twice, use a module-level guard. + +## Resources + +This skill is based on: +- [React Official Docs: You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) +- [React Official Docs: Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects) +- [React Official Docs: Lifecycle of Reactive Effects](https://react.dev/learn/lifecycle-of-reactive-effects) + +## Summary + +The golden rule: **Effects are an escape hatch from React.** If you're not synchronizing with an external system, you probably don't need an Effect. + +Before writing `useEffect`, ask yourself: +1. Is this responding to a user interaction? → Use event handler +2. Is this a value I can calculate from props/state? → Calculate during render +3. Is this resetting state when a prop changes? → Use key prop +4. Is this synchronizing with an external system? → Use Effect with cleanup + +Follow these patterns, and your React code will be more maintainable, performant, and bug-free. diff --git a/.github/skills/react-useeffect/SKILL.md b/.github/skills/react-useeffect/SKILL.md new file mode 100644 index 000000000000..d7c6ffb23fe4 --- /dev/null +++ b/.github/skills/react-useeffect/SKILL.md @@ -0,0 +1,53 @@ +--- +name: react-useeffect +description: React useEffect best practices from official docs. Use when writing/reviewing useEffect, useState for derived values, data fetching, or state synchronization. Teaches when NOT to use Effect and better alternatives. +--- + +# You Might Not Need an Effect + +Effects are an **escape hatch** from React. They let you synchronize with external systems. If there is no external system involved, you shouldn't need an Effect. + +## Quick Reference + +| Situation | DON'T | DO | +|-----------|-------|-----| +| Derived state from props/state | `useState` + `useEffect` | Calculate during render | +| Expensive calculations | `useEffect` to cache | `useMemo` | +| Reset state on prop change | `useEffect` with `setState` | `key` prop | +| User event responses | `useEffect` watching state | Event handler directly | +| Notify parent of changes | `useEffect` calling `onChange` | Call in event handler | +| Fetch data | `useEffect` without cleanup | `useEffect` with cleanup OR framework | + +## When You DO Need Effects + +- Synchronizing with **external systems** (non-React widgets, browser APIs) +- **Subscriptions** to external stores (use `useSyncExternalStore` when possible) +- **Analytics/logging** that runs because component displayed +- **Data fetching** with proper cleanup (or use framework's built-in mechanism) + +## When You DON'T Need Effects + +1. **Transforming data for rendering** - Calculate at top level, re-runs automatically +2. **Handling user events** - Use event handlers, you know exactly what happened +3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` +4. **Chaining state updates** - Calculate all next state in the event handler + +## Decision Tree + +``` +Need to respond to something? +├── User interaction (click, submit, drag)? +│ └── Use EVENT HANDLER +├── Component appeared on screen? +│ └── Use EFFECT (external sync, analytics) +├── Props/state changed and need derived value? +│ └── CALCULATE DURING RENDER +│ └── Expensive? Use useMemo +└── Need to reset state when prop changes? + └── Use KEY PROP on component +``` + +## Detailed Guidance + +- [Anti-Patterns](./anti-patterns.md) - Common mistakes with fixes +- [Better Alternatives](./alternatives.md) - useMemo, key prop, lifting state, useSyncExternalStore diff --git a/.github/skills/react-useeffect/alternatives.md b/.github/skills/react-useeffect/alternatives.md new file mode 100644 index 000000000000..791744ab7049 --- /dev/null +++ b/.github/skills/react-useeffect/alternatives.md @@ -0,0 +1,258 @@ +# Better Alternatives to useEffect + +## 1. Calculate During Render (Derived State) + +For values derived from props or state, just compute them: + +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + + // Runs every render - that's fine and intentional + const fullName = firstName + ' ' + lastName; + const isValid = firstName.length > 0 && lastName.length > 0; +} +``` + +**When to use**: The value can be computed from existing props/state. + +--- + +## 2. useMemo for Expensive Calculations + +When computation is expensive, memoize it: + +```tsx +import { useMemo } from 'react'; + +function TodoList({ todos, filter }) { + const visibleTodos = useMemo( + () => getFilteredTodos(todos, filter), + [todos, filter] + ); +} +``` + +**How to know if it's expensive**: +```tsx +console.time('filter'); +const visibleTodos = getFilteredTodos(todos, filter); +console.timeEnd('filter'); +// If > 1ms, consider memoizing +``` + +**Note**: React Compiler can auto-memoize, reducing manual useMemo needs. + +--- + +## 3. Key Prop to Reset State + +To reset ALL state when a prop changes, use key: + +```tsx +// Parent passes userId as key +function ProfilePage({ userId }) { + return ( + + ); +} + +function Profile({ userId }) { + // All state here resets when userId changes + const [comment, setComment] = useState(''); + const [likes, setLikes] = useState([]); +} +``` + +**When to use**: You want a "fresh start" when an identity prop changes. + +--- + +## 4. Store ID Instead of Object + +To preserve selection when list changes: + +```tsx +// BAD: Storing object that needs Effect to "adjust" +function List({ items }) { + const [selection, setSelection] = useState(null); + + useEffect(() => { + setSelection(null); // Reset when items change + }, [items]); +} + +// GOOD: Store ID, derive object +function List({ items }) { + const [selectedId, setSelectedId] = useState(null); + + // Derived - no Effect needed + const selection = items.find(item => item.id === selectedId) ?? null; +} +``` + +**Benefit**: If item with selectedId exists in new list, selection preserved. + +--- + +## 5. Event Handlers for User Actions + +User clicks/submits/drags should be handled in event handlers, not Effects: + +```tsx +// Event handler knows exactly what happened +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + analytics.track('product_added', { id: product.id }); + } + + function handleCheckoutClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + navigateTo('/checkout'); + } +} +``` + +**Shared logic**: Extract a function, call from both handlers: + +```tsx +function buyProduct() { + addToCart(product); + showNotification(`Added ${product.name}!`); +} + +function handleBuyClick() { buyProduct(); } +function handleCheckoutClick() { buyProduct(); navigateTo('/checkout'); } +``` + +--- + +## 6. useSyncExternalStore for External Stores + +For subscribing to external data (browser APIs, third-party stores): + +```tsx +// Instead of manual Effect subscription +function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(true); + + useEffect(() => { + function update() { setIsOnline(navigator.onLine); } + window.addEventListener('online', update); + window.addEventListener('offline', update); + return () => { + window.removeEventListener('online', update); + window.removeEventListener('offline', update); + }; + }, []); + + return isOnline; +} + +// Use purpose-built hook +import { useSyncExternalStore } from 'react'; + +function subscribe(callback) { + window.addEventListener('online', callback); + window.addEventListener('offline', callback); + return () => { + window.removeEventListener('online', callback); + window.removeEventListener('offline', callback); + }; +} + +function useOnlineStatus() { + return useSyncExternalStore( + subscribe, + () => navigator.onLine, // Client value + () => true // Server value (SSR) + ); +} +``` + +--- + +## 7. Lifting State Up + +When two components need synchronized state, lift it to common ancestor: + +```tsx +// Instead of syncing via Effects between siblings +function Parent() { + const [value, setValue] = useState(''); + + return ( + <> + + + + ); +} +``` + +--- + +## 8. Custom Hooks for Data Fetching + +Extract fetch logic with proper cleanup: + +```tsx +function useData(url) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let ignore = false; + setLoading(true); + + fetch(url) + .then(res => res.json()) + .then(json => { + if (!ignore) { + setData(json); + setError(null); + } + }) + .catch(err => { + if (!ignore) setError(err); + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + + return () => { ignore = true; }; + }, [url]); + + return { data, error, loading }; +} + +// Usage +function SearchResults({ query }) { + const { data, error, loading } = useData(`/api/search?q=${query}`); +} +``` + +**Better**: Use framework's data fetching (React Query, SWR, Next.js, etc.) + +--- + +## Summary: When to Use What + +| Need | Solution | +|------|----------| +| Value from props/state | Calculate during render | +| Expensive calculation | `useMemo` | +| Reset all state on prop change | `key` prop | +| Respond to user action | Event handler | +| Sync with external system | `useEffect` with cleanup | +| Subscribe to external store | `useSyncExternalStore` | +| Share state between components | Lift state up | +| Fetch data | Custom hook with cleanup / framework | diff --git a/.github/skills/react-useeffect/anti-patterns.md b/.github/skills/react-useeffect/anti-patterns.md new file mode 100644 index 000000000000..d35151fdc8db --- /dev/null +++ b/.github/skills/react-useeffect/anti-patterns.md @@ -0,0 +1,290 @@ +# useEffect Anti-Patterns + +## 1. Redundant State for Derived Values + +```tsx +// BAD: Extra state + Effect for derived value +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); +} + +// GOOD: Calculate during rendering +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const fullName = firstName + ' ' + lastName; // Just compute it +} +``` + +**Why it's bad**: Causes extra render pass with stale value, then re-renders with updated value. + +--- + +## 2. Filtering/Transforming Data in Effect + +```tsx +// BAD: Effect to filter list +function TodoList({ todos, filter }) { + const [visibleTodos, setVisibleTodos] = useState([]); + + useEffect(() => { + setVisibleTodos(getFilteredTodos(todos, filter)); + }, [todos, filter]); +} + +// GOOD: Filter during render (memoize if expensive) +function TodoList({ todos, filter }) { + const visibleTodos = useMemo( + () => getFilteredTodos(todos, filter), + [todos, filter] + ); +} +``` + +--- + +## 3. Resetting State on Prop Change + +```tsx +// BAD: Effect to reset state +function ProfilePage({ userId }) { + const [comment, setComment] = useState(''); + + useEffect(() => { + setComment(''); + }, [userId]); +} + +// GOOD: Use key prop +function ProfilePage({ userId }) { + return ; +} + +function Profile({ userId }) { + const [comment, setComment] = useState(''); // Resets automatically +} +``` + +**Why key works**: React treats components with different keys as different components, recreating state. + +--- + +## 4. Event-Specific Logic in Effect + +```tsx +// BAD: Effect for button click result +function ProductPage({ product, addToCart }) { + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name}!`); + } + }, [product]); + + function handleBuyClick() { + addToCart(product); + } +} + +// GOOD: Handle in event handler +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + } +} +``` + +**Why it's bad**: Effect fires on page refresh (isInCart is true), showing notification unexpectedly. + +--- + +## 5. Chains of Effects + +```tsx +// BAD: Effects triggering each other +function Game() { + const [card, setCard] = useState(null); + const [goldCardCount, setGoldCardCount] = useState(0); + const [round, setRound] = useState(1); + const [isGameOver, setIsGameOver] = useState(false); + + useEffect(() => { + if (card?.gold) setGoldCardCount(c => c + 1); + }, [card]); + + useEffect(() => { + if (goldCardCount > 3) { + setRound(r => r + 1); + setGoldCardCount(0); + } + }, [goldCardCount]); + + useEffect(() => { + if (round > 5) setIsGameOver(true); + }, [round]); +} + +// GOOD: Calculate in event handler +function Game() { + const [card, setCard] = useState(null); + const [goldCardCount, setGoldCardCount] = useState(0); + const [round, setRound] = useState(1); + const isGameOver = round > 5; // Derived! + + function handlePlaceCard(nextCard) { + if (isGameOver) throw Error('Game ended'); + + setCard(nextCard); + if (nextCard.gold) { + if (goldCardCount < 3) { + setGoldCardCount(goldCardCount + 1); + } else { + setGoldCardCount(0); + setRound(round + 1); + if (round === 5) alert('Good game!'); + } + } + } +} +``` + +**Why it's bad**: Multiple re-renders (setCard -> setGoldCardCount -> setRound -> setIsGameOver). Also fragile for features like history replay. + +--- + +## 6. Notifying Parent via Effect + +```tsx +// BAD: Effect to notify parent +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + + useEffect(() => { + onChange(isOn); + }, [isOn, onChange]); + + function handleClick() { + setIsOn(!isOn); + } +} + +// GOOD: Notify in same event +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + + function updateToggle(nextIsOn) { + setIsOn(nextIsOn); + onChange(nextIsOn); // Same event, batched render + } + + function handleClick() { + updateToggle(!isOn); + } +} + +// BEST: Fully controlled component +function Toggle({ isOn, onChange }) { + function handleClick() { + onChange(!isOn); + } +} +``` + +--- + +## 7. Passing Data Up to Parent + +```tsx +// BAD: Child fetches, passes up via Effect +function Parent() { + const [data, setData] = useState(null); + return ; +} + +function Child({ onFetched }) { + const data = useSomeAPI(); + + useEffect(() => { + if (data) onFetched(data); + }, [onFetched, data]); +} + +// GOOD: Parent fetches, passes down +function Parent() { + const data = useSomeAPI(); + return ; +} +``` + +**Why**: Data should flow down. Upward flow via Effects makes debugging hard. + +--- + +## 8. Fetching Without Cleanup (Race Condition) + +```tsx +// BAD: No cleanup - race condition +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + fetchResults(query).then(json => { + setResults(json); // "hello" response may arrive after "hell" + }); + }, [query]); +} + +// GOOD: Cleanup ignores stale responses +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + let ignore = false; + + fetchResults(query).then(json => { + if (!ignore) setResults(json); + }); + + return () => { ignore = true; }; + }, [query]); +} +``` + +--- + +## 9. App Initialization in Effect + +```tsx +// BAD: Runs twice in dev, may break auth +function App() { + useEffect(() => { + loadDataFromLocalStorage(); + checkAuthToken(); // May invalidate token on second call! + }, []); +} + +// GOOD: Module-level guard +let didInit = false; + +function App() { + useEffect(() => { + if (!didInit) { + didInit = true; + loadDataFromLocalStorage(); + checkAuthToken(); + } + }, []); +} + +// ALSO GOOD: Module-level execution +if (typeof window !== 'undefined') { + checkAuthToken(); + loadDataFromLocalStorage(); +} +``` diff --git a/src/BloomBrowserUI/AGENTS.md b/src/BloomBrowserUI/AGENTS.md index 0a5ec0663e44..b30c51f8e02e 100644 --- a/src/BloomBrowserUI/AGENTS.md +++ b/src/BloomBrowserUI/AGENTS.md @@ -35,39 +35,10 @@ When working in the front-end, cd to src/BloomBrowserUI ## About React useEffect -Rule 1 — Use useEffect when synchronizing with external systems: -Subscriptions, timers, or event listeners. +See {repository root}/.github/skills/react-useeffect -API calls or other asynchronous external operations. - -Updates to things outside React control (e.g., document.title, localStorage). - -Any side effect that cannot be computed during render. - -Rule 2 — Avoid useEffect when data can be derived or handled internally: - -State can be derived from props, context, or other state — compute in render. - -User interactions can be handled directly in event handlers. - -Local state reset/initialization can be handled by component keys or conditional rendering. - -Computed values can use useMemo or useCallback instead of syncing in an effect. - -Rule 3 — Validation heuristic: - -If removing the effect does not break external behavior, the effect is unnecessary. - -Implementation Tip for AI: - -Prefer pure render computation first. - -Add useEffect only when necessary for external side effects. - -Keep effects minimal and specific to their purpose; avoid overuse. - -Always include a comment before a useEffect explaining what it does and why it is necessary. +If you read that and decide that a useEffect is warranted, you must add a comment justifying why it is necessary. ## UI Tests diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 4e23213dbee6..9f453acecf7a 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -157,10 +157,6 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ () => propsForBloomDialog.open, ); - const [settings, setSettings] = React.useState( - undefined, - ); - const [pageSettings, setPageSettings] = React.useState< IPageSettings | undefined >(undefined); @@ -212,74 +208,66 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ } as unknown as ConfigrValues; }, [settings, pageSettings]); - const [appearanceDisabled, setAppearanceDisabled] = React.useState(false); - - // We use state here to allow the dialog UI to update without permanently changing the settings - // and getting notified of those changes. The changes are persisted when the user clicks OK. - const [theme, setTheme] = React.useState(""); - const [firstPossiblyLegacyCss, setFirstPossiblyLegacyCss] = - React.useState(""); - const [migratedTheme, setMigratedTheme] = React.useState(""); + const [deletedCustomBookStyles, setDeletedCustomBookStyles] = + React.useState(false); const initialPageAttributeSnapshot = React.useRef< ElementAttributeSnapshot | undefined >(undefined); - React.useEffect(() => { + const settings: IBookSettings | undefined = React.useMemo(() => { if (settingsString === "{}") { - return; // leave settings as undefined + return undefined; } if (typeof settingsString === "string") { - setSettings(JSON.parse(settingsString)); - } else { - setSettings(settingsString); + return JSON.parse(settingsString) as IBookSettings; } + return settingsString as unknown as IBookSettings; }, [settingsString]); + // Capture the current page settings and original page attributes once when the dialog mounts + // so Cancel can restore the page accurately; this is safe here because the dialog is only + // opened for an already-loaded editable page and getCurrentPageElement() should exist then. React.useEffect(() => { setPageSettings(getCurrentPageSettings()); initialPageAttributeSnapshot.current = ElementAttributeSnapshot.fromElement(getCurrentPageElement()); }, []); + // If the dialog unmounts while a nested color picker is open, clear the shared visibility flag + // so the parent dialog does not stay hidden after this component is gone. React.useEffect(() => { return () => { setDialogVisibleWhileColorPickerOpen(false); }; }, [setDialogVisibleWhileColorPickerOpen]); - React.useEffect(() => { - setFirstPossiblyLegacyCss( - appearanceUIOptions?.firstPossiblyLegacyCss ?? "", - ); - setMigratedTheme(appearanceUIOptions?.migratedTheme ?? ""); - }, [appearanceUIOptions]); - const bookSettingsTitle = useL10n( "Book and Page Settings", "BookAndPageSettings.Title", ); - React.useEffect(() => { - if (settings?.appearance) { - const liveAppearance = - (settingsToReturnLater?.["appearance"] as - | IAppearanceSettings - | undefined) ?? settings.appearance; - // when we're in legacy, we're just going to disable all the appearance controls - setAppearanceDisabled( - liveAppearance?.cssThemeName === "legacy-5-6", - ); - setTheme(liveAppearance?.cssThemeName ?? ""); - } - }, [settings, settingsToReturnLater]); + const firstPossiblyLegacyCss = deletedCustomBookStyles + ? "" + : (appearanceUIOptions?.firstPossiblyLegacyCss ?? ""); + const migratedTheme = deletedCustomBookStyles + ? "" + : (appearanceUIOptions?.migratedTheme ?? ""); + const liveAppearance = + (settingsToReturnLater?.["appearance"] as + | IAppearanceSettings + | undefined) ?? settings?.appearance; + const appearanceDisabled = liveAppearance?.cssThemeName === "legacy-5-6"; + + // We keep theme as a render-time value from the latest working settings so the dialog reflects + // Configr edits immediately without a second state synchronization layer. + const theme = liveAppearance?.cssThemeName ?? ""; const deleteCustomBookStyles = () => { post( `book/settings/deleteCustomBookStyles?file=${firstPossiblyLegacyCss}`, ); - setFirstPossiblyLegacyCss(""); - setMigratedTheme(""); + setDeletedCustomBookStyles(true); }; const tierAllowsFullPageCoverImage = diff --git a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx index d44c69b4f4c7..39b2d605f309 100644 --- a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx +++ b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx @@ -28,10 +28,6 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { propsForBloomDialog, } = useEventLaunchedBloomDialog("CollectionSettingsDialog"); - const [settings, setSettings] = React.useState( - undefined, - ); - const [settingsString, setSettingsString] = React.useState("{}"); // Fetch collection settings when the dialog opens so we sync with host state. React.useEffect(() => { @@ -41,21 +37,20 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { }); }, [propsForBloomDialog.open]); - const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< - ConfigrValues | undefined - >(undefined); - // Parse the settings JSON for Configr's initial values once it arrives. - React.useEffect(() => { + const settings = React.useMemo((): ConfigrValues | undefined => { if (settingsString === "{}") { - return; // leave settings as undefined + return undefined; } if (typeof settingsString === "string") { - setSettings(JSON.parse(settingsString) as ConfigrValues); - } else { - setSettings(settingsString as ConfigrValues); + return JSON.parse(settingsString) as ConfigrValues; } + return settingsString as unknown as ConfigrValues; }, [settingsString]); + const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< + ConfigrValues | undefined + >(undefined); + return ( = (props) => { addNewColorsToArrayIfNecessary, ]); - // Keep the focus callback current even though we attach DOM listeners only once. - const onInputFocusRef = useRef(props.onInputFocus); - useEffect(() => { - onInputFocusRef.current = props.onInputFocus; - }, [props.onInputFocus]); - - const focusFunc = (ev: FocusEvent) => { - onInputFocusRef.current(ev.currentTarget as HTMLElement); - }; + const onInputFocus = props.onInputFocus; - // Install focus listeners on inputs so the client can restore focus when canvas updates steal it. + // Install focus listeners on inputs so the client can restore focus when canvas updates steal it; + // this effect is necessary because the inputs live in the rendered DOM, not in React props/state, + // and we want the listener to stay aligned with the latest onInputFocus callback. useEffect(() => { const parent = dlgRef.current; if (!parent) { return; } + const focusFunc = (ev: FocusEvent) => { + onInputFocus(ev.currentTarget as HTMLElement); + }; + // When we make incremental color changes while editing one of these inputs, // the process of applying the changed color to the canvas element moves the focus // to the canvas element. This makes it painfully necessary to click back in the input @@ -277,7 +275,7 @@ const ColorPickerDialog: React.FC = (props) => { input.removeEventListener("focus", focusFunc), ); }; - }, []); + }, [onInputFocus]); const convertJsonColorArrayToColorInfos = ( jsonArray: IColorInfo[], From c98b22285fbcd539970a3f235314b43f90332fcd Mon Sep 17 00:00:00 2001 From: Hatton Date: Fri, 27 Mar 2026 20:34:35 -0600 Subject: [PATCH 04/65] fix --- .../BookAndPageSettingsDialog.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 9f453acecf7a..17bea4e30bd8 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -196,6 +196,16 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ return settingsWithoutPage as IBookSettings; }; + const settings: IBookSettings | undefined = React.useMemo(() => { + if (settingsString === "{}") { + return undefined; + } + if (typeof settingsString === "string") { + return JSON.parse(settingsString) as IBookSettings; + } + return settingsString as unknown as IBookSettings; + }, [settingsString]); + const configrInitialValues: ConfigrValues | undefined = React.useMemo(() => { if (!settings || !pageSettings) { @@ -215,16 +225,6 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ ElementAttributeSnapshot | undefined >(undefined); - const settings: IBookSettings | undefined = React.useMemo(() => { - if (settingsString === "{}") { - return undefined; - } - if (typeof settingsString === "string") { - return JSON.parse(settingsString) as IBookSettings; - } - return settingsString as unknown as IBookSettings; - }, [settingsString]); - // Capture the current page settings and original page attributes once when the dialog mounts // so Cancel can restore the page accurately; this is safe here because the dialog is only // opened for an already-loaded editable page and getCurrentPageElement() should exist then. From c62e7a252b2f9d1b98142b0a31b4abe44b8dd2af Mon Sep 17 00:00:00 2001 From: hatton Date: Sat, 28 Mar 2026 14:59:07 +0000 Subject: [PATCH 05/65] Add required useEffect justification comment per AGENTS.md --- .../react_components/color-picking/colorPickerDialog.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx index e795375e5998..99c6f9598988 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx @@ -592,6 +592,8 @@ export const ColorDisplayButton: React.FC = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.initialColor]); + // Clean up the visibility-changed callback when the component unmounts so the parent + // dialog is not left permanently hidden if the color picker was open at unmount time. useEffect(() => { return () => { if (onColorPickerVisibilityChanged) { From 31f7ab6e4986b29975835954220f9558bb9b8333 Mon Sep 17 00:00:00 2001 From: hatton Date: Sat, 28 Mar 2026 15:08:46 +0000 Subject: [PATCH 06/65] Ensure isOpenAlready flag is always reset even if page element restoration throws --- .../BookAndPageSettingsDialog.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 17bea4e30bd8..1ccd30897b0b 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -282,12 +282,15 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ }, [closeDialog]); const cancelAndCloseDialog = React.useCallback(() => { - if (initialPageAttributeSnapshot.current) { - initialPageAttributeSnapshot.current.restoreToElement( - getCurrentPageElement(), - ); + try { + if (initialPageAttributeSnapshot.current) { + initialPageAttributeSnapshot.current.restoreToElement( + getCurrentPageElement(), + ); + } + } finally { + closeDialogAndClearOpenFlag(); } - closeDialogAndClearOpenFlag(); }, [closeDialogAndClearOpenFlag]); function saveSettingsAndCloseDialog() { From 9a8409b341aa47f1ddce53b67f214fc3b0240919 Mon Sep 17 00:00:00 2001 From: Hatton Date: Sat, 28 Mar 2026 09:28:39 -0600 Subject: [PATCH 07/65] Ensure page settings dialog save always clears open flag --- .../BookAndPageSettingsDialog.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 17bea4e30bd8..40e1479b6b59 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -291,20 +291,22 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ }, [closeDialogAndClearOpenFlag]); function saveSettingsAndCloseDialog() { - const latestSettings = - latestSettingsRef.current ?? settingsToReturnLater; - if (latestSettings) { - applyPageSettings( - parsePageSettingsFromConfigrValue(latestSettings), - ); - - const settingsToPost = - removePageSettingsFromConfigrSettings(latestSettings); - // If nothing changed, we don't get any...and don't need to make this call. - postJson("book/settings", settingsToPost); + try { + const latestSettings = + latestSettingsRef.current ?? settingsToReturnLater; + if (latestSettings) { + applyPageSettings( + parsePageSettingsFromConfigrValue(latestSettings), + ); + + const settingsToPost = + removePageSettingsFromConfigrSettings(latestSettings); + // If nothing changed, we don't get any...and don't need to make this call. + postJson("book/settings", settingsToPost); + } + } finally { + closeDialogAndClearOpenFlag(); } - - closeDialogAndClearOpenFlag(); // todo: how do we make the pageThumbnailList reload? It's in a different browser, so // we can't use a global. It listens to websocket, but we currently can only listen, // we cannot send. From 47214c662d49bc5e0b7e67d1bc09c4f24a631964 Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 30 Dec 2025 16:21:02 -0700 Subject: [PATCH 08/65] BL-15642 Intro Page Settings --- .../prompts/bloom-test-CURRENTPAGE.prompt.md | 4 + .../skills/reviewable-thread-replies/SKILL.md | 93 ++ .../localization/en/BloomMediumPriority.xlf | 40 + .../BookAndPageSettingsDialog.tsx | 471 +++++++ .../BookSettingsConfigrPages.tsx | 775 ++++++++++++ .../FieldVisibilityGroup.tsx | 10 +- .../PageSettingsConfigrPages.tsx | 525 ++++++++ .../StyleAndFontTable.tsx | 0 .../appearanceThemeUtils.ts | 0 .../bookSettings/BookSettingsDialog.tsx | 1098 ----------------- .../bookEdit/css/origamiEditing.less | 36 +- .../js/CanvasElementContextControls.tsx | 2 + src/BloomBrowserUI/bookEdit/js/origami.ts | 104 +- .../bookEdit/js/workspaceFrames.ts | 6 +- .../pageThumbnailList/PageThumbnail.tsx | 4 + .../pageThumbnailList/pageThumbnailList.tsx | 20 +- .../toolbox/canvas/customXmatterPage.tsx | 4 +- .../bookEdit/toolbox/toolbox.ts | 13 +- src/BloomBrowserUI/bookEdit/workspaceRoot.ts | 19 +- .../collection/CollectionSettingsDialog.tsx | 31 +- .../collectionsTab/BookButton.tsx | 2 +- .../CollectionsTabBookPane.tsx | 1 - .../react_components/BookInfoIndicator.tsx | 4 +- .../react_components/bloomButton.tsx | 3 + .../color-picking/bloomPalette.ts | 26 + .../color-picking/bloomSketchPicker.tsx | 1 + .../color-picking/colorPicker.tsx | 229 +++- .../color-picking/colorPickerDialog.tsx | 486 +++++--- .../colorDisplayButton.uitest.ts | 124 ++ .../colorDisplayButtonTestHarness.tsx | 42 + .../component-tests/colorPicker.uitest.ts | 105 ++ .../colorPickerManualHarness.tsx | 41 + .../colorPickerTestHarness.tsx | 41 + .../component-tests/show-component.uitest.ts | 58 + .../color-picking/hexColorInput.tsx | 92 +- .../react_components/color-picking/show.sh | 13 + .../react_components/color-picking/test.sh | 15 + .../utils/ElementAttributeSnapshot.ts | 50 + src/BloomExe/Book/AppearanceSettings.cs | 2 + src/BloomExe/Book/HtmlDom.cs | 46 + src/BloomExe/Edit/EditingView.cs | 18 +- .../web/controllers/EditingViewApi.cs | 11 + .../Book/AppearanceSettingsTests.cs | 19 + .../appearance-theme-default.css | 3 + .../appearance-theme-rounded-border-ebook.css | 4 +- .../appearance-theme-zero-margin-ebook.css | 6 +- src/content/bookLayout/pageNumbers.less | 5 + yarn.lock | 4 + 48 files changed, 3345 insertions(+), 1361 deletions(-) create mode 100644 .github/prompts/bloom-test-CURRENTPAGE.prompt.md create mode 100644 .github/skills/reviewable-thread-replies/SKILL.md create mode 100644 src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx create mode 100644 src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx rename src/BloomBrowserUI/bookEdit/{bookSettings => bookAndPageSettings}/FieldVisibilityGroup.tsx (93%) create mode 100644 src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx rename src/BloomBrowserUI/bookEdit/{bookSettings => bookAndPageSettings}/StyleAndFontTable.tsx (100%) rename src/BloomBrowserUI/bookEdit/{bookSettings => bookAndPageSettings}/appearanceThemeUtils.ts (100%) delete mode 100644 src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerManualHarness.tsx create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerTestHarness.tsx create mode 100644 src/BloomBrowserUI/react_components/color-picking/component-tests/show-component.uitest.ts create mode 100644 src/BloomBrowserUI/react_components/color-picking/show.sh create mode 100644 src/BloomBrowserUI/react_components/color-picking/test.sh create mode 100644 src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts create mode 100644 yarn.lock diff --git a/.github/prompts/bloom-test-CURRENTPAGE.prompt.md b/.github/prompts/bloom-test-CURRENTPAGE.prompt.md new file mode 100644 index 000000000000..231bfff7cb13 --- /dev/null +++ b/.github/prompts/bloom-test-CURRENTPAGE.prompt.md @@ -0,0 +1,4 @@ +--- +description: use browser tools to test and debug +--- +The backend should already be running and serving a page at http://localhost:/bloom/CURRENTPAGE. is usually 8089. If i include a port number, use that, otherwise use 8089. You may use chrome-devtools-mcp, playwright-mcp, or other browser management tools. If you can't find any, use askQuestions tool to ask me to enable something for you to use. diff --git a/.github/skills/reviewable-thread-replies/SKILL.md b/.github/skills/reviewable-thread-replies/SKILL.md new file mode 100644 index 000000000000..466157a088c5 --- /dev/null +++ b/.github/skills/reviewable-thread-replies/SKILL.md @@ -0,0 +1,93 @@ +--- +name: reviewable-thread-replies +description: 'Reply to GitHub and Reviewable PR discussion threads one-by-one. Use whenever the user asks you to respond to review comments with accurate in-thread replies and verification.' +argument-hint: 'Repo/PR and target comments to reply to (for example: BloomBooks/BloomDesktop#7557 + specific discussion links/IDs)' +note: it's not clear that this skill is adequately developed, it's not clear that it works. +--- + +# Reviewable Thread Replies + +## What This Skill Does +Posts in-thread replies on both: +- GitHub PR review comments (`discussion_r...`) +- Reviewable-only discussion anchors quoted in review bodies + +## When To Use +- The user asks you to respond to one or more PR comments. +- Some comments are directly replyable on GitHub, while others only exist as Reviewable anchors. +- You need one response per thread, posted in the right place. + +## Inputs +- figure out the PR using the gh cli +- Target links or IDs (GitHub `discussion_r...` or Reviewable `#-...` anchors), or enough context to discover them. +- Reply text supplied by user, or instruction to compose replies from thread context. + +## Required Reply Format +- Every posted reply must begin with `[]`. +- Do not prepend workflow labels (for example `Will do, TODO`). + +## Procedure +1. Collect and normalize targets. +- Build a list of target threads with: `target`, `context`, `response`. +- If response text is not provided, compose a concise response from the thread context. +- Separate items into: + - GitHub direct thread comments (have comment IDs / `discussion_r...`). + - Reviewable-only threads (anchor IDs like `-Oko...`). + +2. Post direct GitHub thread replies first. +- Use GitHub PR review comment reply API/tool for each direct comment ID. +- Post exactly one response per thread. +- Verify the new reply IDs/URLs are returned. + +3. Open Reviewable, give the user time to authenticate. +- Navigate to the PR in Reviewable. +- If the user session is not active, use Reviewable sign-in flow and confirm identity before posting. + +4. Reply to Reviewable-only threads one by one. +- For each discussion anchor: + - Navigate to the anchor. + - Find the thread reply input for that discussion. + - Post response text with the required `[]` prefix. + - Avoid adding status macros or extra prefixes. +- Wait for each post to render before moving to the next thread. + +5. Verification pass. +- Re-check every target thread and confirm the expected response appears. +- Confirm no target remains unreplied due to navigation/context loss. +- Confirm no accidental text prefixes were added. + +## Decision Points +- If target has GitHub comment ID: use GitHub API/tool reply path. +- If target exists only in Reviewable anchor: use browser automation path. +- If Reviewable shows sign-in or disabled reply controls: authenticate first, then retry. +- Never click `resolve`, `done`, or `acknowledge` controls and never change discussion resolution state. +- If reply input transitions into a temporary composer panel: + - Submit without modifying response text semantics. + - Keep the required `[]` prefix and avoid workflow labels. +- If posted text does not match intended response: correct immediately before continuing. + +## Quality Criteria +- Exactly one intended response posted per target thread. +- Responses are correct for thread context and begin with `[]`. +- No unwanted prefixes like `Will do, TODO`. +- No unresolved posting errors left undocumented. +- Final status includes: posted targets and skipped/failed targets. + +## Guardrails +- Do not post broad summary comments when thread-level replies were requested. +- Do not resolve, acknowledge, dismiss, or otherwise change PR discussion status; leave resolution actions to humans. +- Do not rely on internal/private page APIs for mutation unless officially supported and permission-safe. +- Do not assume draft state implies publication; verify thread-visible posted output. +- Do not continue after repeated auth/permission failures without reporting the blocker. + +## Quick Command Hints +- List PR review comments: +```bash + gh api repos///pulls//comments --paginate +``` + +- List PR reviews (to inspect review-body quoted discussions): +```bash + gh api repos///pulls//reviews --paginate +``` + diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index 4d540aaf3a23..ee25020f4fe0 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -732,6 +732,46 @@ BookSettings.Title the heading of the dialog + + Book and Page Settings + ID: BookAndPageSettings.Title + the heading of the dialog + + + Book + ID: BookAndPageSettings.BookArea + Area label for tabs/pages that affect all pages in the current book. + + + Book settings apply to all of the pages of the current book. + ID: BookAndPageSettings.BookArea.Description + Description text shown for the Book area in the combined Book and Page Settings dialog. + + + Page + ID: BookAndPageSettings.PageArea + Area label for tabs/pages that affect only the current page. + + + Page settings apply to the current page. + ID: BookAndPageSettings.PageArea.Description + Description text shown for the Page area in the combined Book and Page Settings dialog. + + + Colors + ID: BookAndPageSettings.Colors + Label for the page-level Colors page within the combined Book and Page Settings dialog. + + + Page Settings + ID: PageSettings.Title + Title text for the standalone Page Settings dialog and the page settings button label above custom pages. + + + Open Page Settings... + ID: PageSettings.OpenTooltip + Tooltip shown when hovering over the Page Settings button above a custom page. + Max Image Size BookSettings.eBook.Image.MaxResolution diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx new file mode 100644 index 000000000000..4e23213dbee6 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -0,0 +1,471 @@ +import { css } from "@emotion/react"; +import { ConfigrArea, ConfigrPane, ConfigrValues } from "@sillsdev/config-r"; +import * as React from "react"; +import { kBloomBlue } from "../../bloomMaterialUITheme"; +import { + BloomDialog, + DialogBottomButtons, + DialogMiddle, + DialogTitle, +} from "../../react_components/BloomDialog/BloomDialog"; +import { useSetupBloomDialog } from "../../react_components/BloomDialog/BloomDialogPlumbing"; +import { + DialogCancelButton, + DialogOkButton, +} from "../../react_components/BloomDialog/commonDialogComponents"; +import { + post, + postJson, + useApiBoolean, + useApiObject, + useApiStringState, +} from "../../utils/bloomApi"; +import { useL10n } from "../../react_components/l10nHooks"; +import { getWorkspaceBundleExports } from "../js/workspaceFrames"; +import { ElementAttributeSnapshot } from "../../utils/ElementAttributeSnapshot"; +import { useGetFeatureStatus } from "../../react_components/featureStatus"; +import { + arePageSettingsEquivalent, + applyPageSettings, + getCurrentPageElement, + getCurrentPageSettings, + IPageSettings, + parsePageSettingsFromConfigrValue, + usePageSettingsAreaDefinition, +} from "./PageSettingsConfigrPages"; +import { useBookSettingsAreaDefinition } from "./BookSettingsConfigrPages"; + +let isOpenAlready = false; +const kBookSettingsDialogWidthPx = 900; +const kBookSettingsDialogHeightPx = 720; + +type IPageStyle = { label: string; value: string }; +type IPageStyles = Array; +type IAppearanceUIOptions = { + firstPossiblyLegacyCss?: string; + migratedTheme?: string; + themeNames: IPageStyles; +}; + +// Stuff we find in the appearance property of the object we get from the book/settings api. +// Not yet complete +export interface IAppearanceSettings { + cssThemeName: string; +} + +// Stuff we get from the book/settings api. +// Not yet complete +export interface IBookSettings { + appearance?: IAppearanceSettings; + firstPossiblyLegacyCss?: string; +} + +// Stuff we get from the book/settings/overrides api. +// The branding and xmatter objects contain the corresponding settings, +// using the same keys as appearance.json. Currently the values are all +// booleans. +interface IOverrideInformation { + branding: object; + xmatter: object; + brandingName: string; + xmatterName: string; +} + +export const BookAndPageSettingsDialog: React.FunctionComponent<{ + initiallySelectedPageKey?: string; +}> = (props) => { + const { closeDialog, propsForBloomDialog } = useSetupBloomDialog({ + initiallyOpen: true, + dialogFrameProvidedExternally: false, + }); + + const appearanceUIOptions: IAppearanceUIOptions = + useApiObject( + "book/settings/appearanceUIOptions", + { + themeNames: [], + }, + ); + + // If we pass a new default value to useApiObject on every render, it will query the host + // every time and then set the result, which triggers a new render, making an infinite loop. + const defaultOverrides = React.useMemo(() => { + return { + xmatter: {}, + branding: {}, + xmatterName: "", + brandingName: "", + }; + }, []); + + const overrideInformation: IOverrideInformation | undefined = + useApiObject( + "book/settings/overrides", + defaultOverrides, + ); + + const [pageSizeSupportsFullBleed] = useApiBoolean( + "book/settings/pageSizeSupportsFullBleed", + true, + ); + + const xmatterLockedBy = useL10n( + "Locked by {0} Front/Back matter", + "BookSettings.LockedByXMatter", + "", + overrideInformation?.xmatterName, + ); + + const brandingLockedBy = useL10n( + "Locked by {0} Branding", + "BookSettings.LockedByBranding", + "", + overrideInformation?.brandingName, + ); + + // This is a helper function to make it easier to pass the override information + function getAdditionalProps(subPath: string): { + path: string; + overrideValue: T; + overrideDescription?: string; + } { + // some properties will be overridden by branding and/or xmatter + const xmatterOverride: T | undefined = + overrideInformation?.xmatter?.[subPath]; + const brandingOverride = overrideInformation?.branding?.[subPath]; + const override = xmatterOverride ?? brandingOverride; + // nb: xmatterOverride can be boolean, hence the need to spell out !==undefined + let description = + xmatterOverride !== undefined ? xmatterLockedBy : undefined; + if (!description) { + // xmatter wins if both are present + description = + brandingOverride !== undefined ? brandingLockedBy : undefined; + } + // make a an object that can be spread as props in any of the Configr controls + return { + path: "appearance." + subPath, + overrideValue: override as T, + // if we're disabling all appearance controls (e.g. because we're in legacy), don't list a second reason for this overload + overrideDescription: appearanceDisabled ? "" : description, + }; + } + + const [settingsString] = useApiStringState( + "book/settings", + "{}", + () => propsForBloomDialog.open, + ); + + const [settings, setSettings] = React.useState( + undefined, + ); + + const [pageSettings, setPageSettings] = React.useState< + IPageSettings | undefined + >(undefined); + + const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< + ConfigrValues | undefined + >(undefined); + const latestSettingsRef = React.useRef( + undefined, + ); + const dialogRef = React.useRef(null); + + const setDialogVisibleWhileColorPickerOpen = React.useCallback( + (open: boolean) => { + const dialogRoot = dialogRef.current?.closest(".MuiDialog-root"); + if (!(dialogRoot instanceof HTMLElement)) { + return; + } + if (open) { + dialogRoot.style.visibility = "hidden"; + dialogRoot.style.pointerEvents = "none"; + } else { + dialogRoot.style.visibility = ""; + dialogRoot.style.pointerEvents = ""; + } + }, + [], + ); + + const removePageSettingsFromConfigrSettings = ( + settingsValue: ConfigrValues, + ): IBookSettings => { + const settingsWithoutPage = { + ...settingsValue, + } as Record; + delete settingsWithoutPage["page"]; + return settingsWithoutPage as IBookSettings; + }; + + const configrInitialValues: ConfigrValues | undefined = + React.useMemo(() => { + if (!settings || !pageSettings) { + return undefined; + } + + return { + ...settings, + page: pageSettings.page, + } as unknown as ConfigrValues; + }, [settings, pageSettings]); + + const [appearanceDisabled, setAppearanceDisabled] = React.useState(false); + + // We use state here to allow the dialog UI to update without permanently changing the settings + // and getting notified of those changes. The changes are persisted when the user clicks OK. + const [theme, setTheme] = React.useState(""); + const [firstPossiblyLegacyCss, setFirstPossiblyLegacyCss] = + React.useState(""); + const [migratedTheme, setMigratedTheme] = React.useState(""); + + const initialPageAttributeSnapshot = React.useRef< + ElementAttributeSnapshot | undefined + >(undefined); + + React.useEffect(() => { + if (settingsString === "{}") { + return; // leave settings as undefined + } + if (typeof settingsString === "string") { + setSettings(JSON.parse(settingsString)); + } else { + setSettings(settingsString); + } + }, [settingsString]); + + React.useEffect(() => { + setPageSettings(getCurrentPageSettings()); + initialPageAttributeSnapshot.current = + ElementAttributeSnapshot.fromElement(getCurrentPageElement()); + }, []); + + React.useEffect(() => { + return () => { + setDialogVisibleWhileColorPickerOpen(false); + }; + }, [setDialogVisibleWhileColorPickerOpen]); + + React.useEffect(() => { + setFirstPossiblyLegacyCss( + appearanceUIOptions?.firstPossiblyLegacyCss ?? "", + ); + setMigratedTheme(appearanceUIOptions?.migratedTheme ?? ""); + }, [appearanceUIOptions]); + + const bookSettingsTitle = useL10n( + "Book and Page Settings", + "BookAndPageSettings.Title", + ); + + React.useEffect(() => { + if (settings?.appearance) { + const liveAppearance = + (settingsToReturnLater?.["appearance"] as + | IAppearanceSettings + | undefined) ?? settings.appearance; + // when we're in legacy, we're just going to disable all the appearance controls + setAppearanceDisabled( + liveAppearance?.cssThemeName === "legacy-5-6", + ); + setTheme(liveAppearance?.cssThemeName ?? ""); + } + }, [settings, settingsToReturnLater]); + + const deleteCustomBookStyles = () => { + post( + `book/settings/deleteCustomBookStyles?file=${firstPossiblyLegacyCss}`, + ); + setFirstPossiblyLegacyCss(""); + setMigratedTheme(""); + }; + + const tierAllowsFullPageCoverImage = + useGetFeatureStatus("fullPageCoverImage")?.enabled; + + const tierAllowsFullBleed = useGetFeatureStatus("PrintshopReady")?.enabled; + + const closeDialogAndClearOpenFlag = React.useCallback(() => { + latestSettingsRef.current = undefined; + isOpenAlready = false; + closeDialog(); + }, [closeDialog]); + + const cancelAndCloseDialog = React.useCallback(() => { + if (initialPageAttributeSnapshot.current) { + initialPageAttributeSnapshot.current.restoreToElement( + getCurrentPageElement(), + ); + } + closeDialogAndClearOpenFlag(); + }, [closeDialogAndClearOpenFlag]); + + function saveSettingsAndCloseDialog() { + const latestSettings = + latestSettingsRef.current ?? settingsToReturnLater; + if (latestSettings) { + applyPageSettings( + parsePageSettingsFromConfigrValue(latestSettings), + ); + + const settingsToPost = + removePageSettingsFromConfigrSettings(latestSettings); + // If nothing changed, we don't get any...and don't need to make this call. + postJson("book/settings", settingsToPost); + } + + closeDialogAndClearOpenFlag(); + // todo: how do we make the pageThumbnailList reload? It's in a different browser, so + // we can't use a global. It listens to websocket, but we currently can only listen, + // we cannot send. + } + + const bookSettingsArea = useBookSettingsAreaDefinition({ + appearanceDisabled, + tierAllowsFullPageCoverImage, + tierAllowsFullBleed, + pageSizeSupportsFullBleed, + settings, + settingsToReturnLater, + getAdditionalProps, + firstPossiblyLegacyCss, + theme, + migratedTheme, + deleteCustomBookStyles, + saveSettingsAndCloseDialog, + onColorPickerVisibilityChanged: setDialogVisibleWhileColorPickerOpen, + themeNames: appearanceUIOptions.themeNames, + }); + + const pageSettingsArea = usePageSettingsAreaDefinition({ + onColorPickerVisibilityChanged: setDialogVisibleWhileColorPickerOpen, + }); + + return ( + cancelAndCloseDialog()} + onCancel={() => cancelAndCloseDialog()} + draggable={false} + maxWidth={false} + > + + + {configrInitialValues && ( + { + const parsedPageSettings = + parsePageSettingsFromConfigrValue(s); + const isInitialConfigrEcho = + !settingsToReturnLater && + !!pageSettings && + arePageSettingsEquivalent( + parsedPageSettings, + pageSettings, + ); + + // Config-r may call onChange while rendering, so defer state updates. + latestSettingsRef.current = s; + window.setTimeout(() => { + setSettingsToReturnLater(s); + }, 0); + + if (isInitialConfigrEcho) { + return; + } + + applyPageSettings(parsedPageSettings); + }} + initiallySelectedTopLevelPageKey={ + props.initiallySelectedPageKey + } + > + + {bookSettingsArea.pages} + + + {pageSettingsArea.pages} + + + )} + + + + + + + ); +}; + +export function showBookSettingsDialog(initiallySelectedPageKey?: string) { + // once Bloom's tab bar is also in react, it won't be possible + // to open another copy of this without closing it first, but + // for now, we need to prevent that. + if (!isOpenAlready) { + isOpenAlready = true; + try { + getWorkspaceBundleExports().ShowEditViewDialog( + , + ); + } catch (error) { + isOpenAlready = false; + throw error; + } + } +} diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx new file mode 100644 index 000000000000..fe6c494c4d93 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx @@ -0,0 +1,775 @@ +import { css } from "@emotion/react"; +import { Slider, Typography } from "@mui/material"; +import { ThemeProvider } from "@mui/material/styles"; +import { + ConfigrBoolean, + ConfigrCustomObjectInput, + ConfigrCustomStringInput, + ConfigrGroup, + ConfigrPage, + ConfigrSelect, + ConfigrStatic, +} from "@sillsdev/config-r"; +import { default as TrashIcon } from "@mui/icons-material/Delete"; +import * as React from "react"; +import { kBloomBlue, lightTheme } from "../../bloomMaterialUITheme"; +import { NoteBox, WarningBox } from "../../react_components/boxes"; +import { Div, P } from "../../react_components/l10nComponents"; +import { useL10n } from "../../react_components/l10nHooks"; +import { PWithLink } from "../../react_components/pWithLink"; +import { BloomSubscriptionIndicatorIconAndText } from "../../react_components/requiresSubscription"; +import { BloomPalette } from "../../react_components/color-picking/bloomPalette"; +import { + ColorDisplayButton, + DialogResult, +} from "../../react_components/color-picking/colorPickerDialog"; +import { FieldVisibilityGroup } from "./FieldVisibilityGroup"; +import { StyleAndFontTable } from "./StyleAndFontTable"; + +// Should stay in sync with AppearanceSettings.PageNumberPosition +enum PageNumberPosition { + Automatic = "automatic", + Left = "left", + Center = "center", + Right = "right", + Hidden = "hidden", +} + +type Resolution = { + maxWidth: number; + maxHeight: number; +}; + +type BookSettingsAreaProps = { + appearanceDisabled: boolean; + tierAllowsFullPageCoverImage?: boolean; + tierAllowsFullBleed?: boolean; + pageSizeSupportsFullBleed: boolean; + settings: object | undefined; + settingsToReturnLater: object | undefined; + getAdditionalProps: (subPath: string) => { + path: string; + overrideValue: T; + overrideDescription?: string; + }; + firstPossiblyLegacyCss: string; + theme: string; + migratedTheme: string; + deleteCustomBookStyles: () => void; + saveSettingsAndCloseDialog: () => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; + themeNames: Array<{ label: string; value: string }>; +}; + +export type IConfigrAreaDefinition = { + label: string; + pageKey: string; + content: string; + pages: React.ReactElement[]; +}; + +export const useBookSettingsAreaDefinition = ( + props: BookSettingsAreaProps, +): IConfigrAreaDefinition => { + const bookAreaLabel = useL10n("Book", "BookAndPageSettings.BookArea"); + const bookAreaDescription = useL10n( + "Book settings apply to all of the pages of the current book.", + "BookAndPageSettings.BookArea.Description", + ); + + const coverLabel = useL10n("Cover", "BookSettings.CoverGroupLabel"); + const contentPagesLabel = useL10n( + "Content Pages", + "BookSettings.ContentPagesGroupLabel", + ); + const printPublishingLabel = useL10n( + "Print Publishing", + "BookSettings.PrintPublishingGroupLabel", + ); + const languagesToShowNormalSubgroupLabel = useL10n( + "Languages to show in normal text boxes", + "BookSettings.NormalTextBoxLangsLabel", + "", + ); + const themeLabel = useL10n("Page Theme", "BookSettings.PageThemeLabel", ""); + const themeDescription = useL10n( + "", // will be translated or the English will come from the xliff + "BookSettings.Theme.Description", + ); + + const coverBackgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + + const whatToShowOnCoverLabel = useL10n( + "Front Cover", + "BookSettings.WhatToShowOnCover", + ); + + const showLanguageNameLabel = useL10n( + "Show Language Name", + "BookSettings.ShowLanguageName", + ); + const showTopicLabel = useL10n("Show Topic", "BookSettings.ShowTopic"); + const showCreditsLabel = useL10n( + "Show Credits", + "BookSettings.ShowCredits", + ); + const pageNumbersLabel = useL10n( + "Page Numbers", + "BookSettings.PageNumbers", + ); + const pageNumberLocationNote = useL10n( + "Note: some Page Themes may not know how to change the location of the Page Number.", + "BookSettings.PageNumberLocationNote", + ); + const pageNumberPositionAutomaticLabel = useL10n( + "(Automatic)", + "BookSettings.PageNumbers.Automatic", + ); + const pageNumberPositionLeftLabel = useL10n( + "Left", + "BookSettings.PageNumbers.Left", + ); + const pageNumberPositionCenterLabel = useL10n( + "Center", + "BookSettings.PageNumbers.Center", + ); + const pageNumberPositionRightLabel = useL10n( + "Right", + "BookSettings.PageNumbers.Right", + ); + const pageNumberPositionHiddenLabel = useL10n( + "Hidden", + "BookSettings.PageNumbers.Hidden", + ); + + const resolutionLabel = useL10n("Resolution", "BookSettings.Resolution"); + const bloomPubLabel = useL10n("eBooks", "PublishTab.bloomPUBButton"); + + const advancedLayoutLabel = useL10n( + "Advanced Layout", + "BookSettings.AdvancedLayoutLabel", + ); + const textPaddingLabel = useL10n( + "Text Padding", + "BookSettings.TopLevelTextPaddingLabel", + ); + const textPaddingDescription = useL10n( + "Smart spacing around text boxes. Works well for simple pages, but may not suit custom layouts.", + "BookSettings.TopLevelTextPadding.Description", + ); + const textPaddingDefaultLabel = useL10n( + "Default (set by Theme)", + "BookSettings.TopLevelTextPadding.DefaultLabel", + ); + const textPadding1emLabel = useL10n( + "1 em (font size)", + "BookSettings.TopLevelTextPadding.1emLabel", + ); + + const gutterLabel = useL10n("Page Gutter", "BookSettings.Gutter.Label"); + const gutterDescription = useL10n( + "Extra space between pages near the book spine. Increase this for books with many pages to ensure text isn't lost in the binding. This gap is applied to each side of the spine.", + "BookSettings.Gutter.Description", + ); + const gutterDefaultLabel = useL10n( + "Default (set by Theme)", + "BookSettings.Gutter.DefaultLabel", + ); + + const coverIsImageLabel = useL10n( + "Fill the front cover with a single image", + "BookSettings.CoverIsImage", + ); + const coverIsImageDescription = useL10n( + "Replace the front cover content with a single full-bleed image. See [Full Page Cover Images](https://docs.bloomlibrary.org/full-page-cover-images) for information on sizing your image to fit.", + "BookSettings.CoverIsImage.Description.V2", + ); + + const fullBleedLabel = useL10n( + "Use full bleed page layout", + "BookSettings.FullBleed", + ); + const fullBleedDescription = useL10n( + "Enable full bleed layout for printing. This turns on the [Print Bleed](https://en.wikipedia.org/wiki/Bleed_%28printing%29) indicators on paper layouts. See [Full Bleed Layout](https://docs.bloomlibrary.org/full-bleed) for more information.", + "BookSettings.FullBleed.Description", + ); + + const coverColorPickerControl = React.useCallback( + (coverColorProps: { + value: string; + disabled: boolean; + onChange: (value: string) => void; + }) => { + return ( + + ); + }, + [props.onColorPickerVisibilityChanged], + ); + + return { + label: bookAreaLabel, + pageKey: "bookArea", + content: bookAreaDescription, + pages: [ + + {props.appearanceDisabled && ( + + +
+ The selected page theme does not support the + following settings. +
+
+
+ )} + +
+ ( + `coverIsImage`, + )} + disabled={ + props.appearanceDisabled || + !props.tierAllowsFullPageCoverImage + } + /> +
+ +
+
+ + + ( + `cover-languageName-show`, + )} + /> + ( + `cover-topic-show`, + )} + /> + ( + `cover-creditsRow-show`, + )} + /> +
+ + ( + `cover-background-color`, + )} + /> + +
, + + { + // This group of four possible messages...sometimes none of them shows, so there are five options... + // is very similar to the one in BookInfoIndicator.tsx. If you change one, you may need to change the other. + // In particular, the logic for which to show and the text of the messages should be kept in sync. + // I'm not seeing a clean way to reuse the logic. Some sort of higher-order component might work, + // but I don't think the logic is complex enough to be worth it, when only used in two places. + } + {props.firstPossiblyLegacyCss.length > 0 && + props.theme === "legacy-5-6" && ( + + + + + + )} + {props.firstPossiblyLegacyCss === "customBookStyles.css" && + props.theme !== "legacy-5-6" && ( + + +
+ {props.migratedTheme ? ( + + ) : ( + + )} +
+ props.deleteCustomBookStyles() + } + > + +
+ Delete{" "} + {props.firstPossiblyLegacyCss} +
+
+
+
+
+ )} + {props.firstPossiblyLegacyCss.length > 0 && + props.firstPossiblyLegacyCss !== "customBookStyles.css" && + props.theme !== "legacy-5-6" && ( + + + + + + )} + + {/* Wrapping these two in a div prevents Config-R from sticking a divider between them */} +
+ { + return { + label: x.label, + value: x.value, + }; + })} + description={themeDescription} + /> + {props.appearanceDisabled && ( + +
+ The selected page theme does not support the + following settings. +
+
+ )} +
+ ( + `pageNumber-position`, + )} + options={[ + { + label: pageNumberPositionAutomaticLabel, + value: PageNumberPosition.Automatic, + }, + { + label: pageNumberPositionLeftLabel, + value: PageNumberPosition.Left, + }, + { + label: pageNumberPositionCenterLabel, + value: PageNumberPosition.Center, + }, + { + label: pageNumberPositionRightLabel, + value: PageNumberPosition.Right, + }, + { + label: "--", + value: "--", + }, + { + label: pageNumberPositionHiddenLabel, + value: PageNumberPosition.Hidden, + }, + ]} + description={pageNumberLocationNote} + /> +
+ + + + + ( + `topLevel-text-padding`, + )} + /> + (`page-gutter`)} + /> + +
, + + +
+ (`fullBleed`)} + disabled={ + !props.tierAllowsFullBleed || + !props.pageSizeSupportsFullBleed + } + /> +
+ +
+
+
+
, + + {/* note that this is used for bloomPUB and ePUB, but we don't have separate settings so we're putting them in bloomPUB and leaving it to c# code to use it for ePUB as well. */} + + + + , + + + + +
+

+ When you publish a book to the web or as an + ebook, Bloom will flag any problematic + fonts. For example, we cannot legally host + most Microsoft fonts on BloomLibrary.org. +

+

+ The following table shows where fonts have + been used. +

+
+
+ +
+
+
, + ], + }; +}; + +const BloomResolutionSlider: React.FunctionComponent< + React.PropsWithChildren<{ + path: string; + label: string; + }> +> = (props) => { + return ( +
+ + control={BloomResolutionSliderInner} + {...props} + > +
+ Bloom reduces images to a maximum size to make books easier to + view over poor internet connections and take up less space on + phones. +
+
+ ); +}; + +const BloomResolutionSliderInner: React.FunctionComponent<{ + value: Resolution; + onChange: (value: Resolution) => void; +}> = (props) => { + const sizes = [ + { l: "Small", w: 600, h: 600 }, + { l: "HD", w: 1280, h: 720 }, + { l: "Full HD", w: 1920, h: 1080 }, + { l: "4K", w: 3840, h: 2160 }, + ]; + let currentIndex = sizes.findIndex((x) => x.w === props.value.maxWidth); + if (currentIndex === -1) { + currentIndex = 1; // See BL-12803. + } + const current = sizes[currentIndex]; + const currentLabel = useL10n( + current.l, + `BookSettings.eBook.Image.MaxResolution.${current.l}`, + ); + + return ( + +
+ {`${currentLabel}`} + { + return `${current.w}x${current.h}`; + }} + onChange={(e, value) => { + props.onChange({ + maxWidth: sizes[value as number].w, + maxHeight: sizes[value as number].h, + }); + }} + valueLabelDisplay="auto" + > +
+
+ ); +}; + +const CoverColorPickerForConfigr: React.FunctionComponent<{ + value: string; + disabled: boolean; + onChange: (value: string) => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const coverBackgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + /> + ); +}; + +export const MessageUsingLegacyThemeWithIncompatibleCss: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( + + The {0} stylesheet of this book is incompatible with modern themes. + Bloom is using it because the book is using the Legacy-5-6 theme. + Click [here] for more information. + + ); +}; + +export const MessageUsingMigratedThemeInsteadOfIncompatibleCss: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( +
+ Bloom found a known version of {props.fileName} in this book and + replaced it with a modern theme. You can delete it unless you still + need to publish the book from an earlier version of Bloom. +
+ ); +}; + +export const MessageIgnoringIncompatibleCssCanDelete: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( + + The + {props.fileName} stylesheet of this book is incompatible with modern + themes. Bloom is currently ignoring it. If you don't need those + customizations any more, you can delete your + {props.fileName}. Click [here] for more information. + + ); +}; + +export const MessageIgnoringIncompatibleCss: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( + + The {props.fileName} stylesheet of this book is incompatible with + modern themes. Bloom is currently ignoring it. Click [here] for more + information. + + ); +}; diff --git a/src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx similarity index 93% rename from src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx rename to src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx index de7d6d65455b..fb6578287dd8 100644 --- a/src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx @@ -23,7 +23,7 @@ export const FieldVisibilityGroup: React.FunctionComponent<{ labelFrame: string; labelFrameL10nKey: string; settings: object | undefined; - settingsToReturnLater: string | object | undefined; + settingsToReturnLater: object | undefined; disabled: boolean; L1MustBeTurnedOn?: boolean; @@ -88,13 +88,7 @@ export const FieldVisibilityGroup: React.FunctionComponent<{ const [showL1, showL2, showL3, numberShowing] = useMemo(() => { let appearance = props.settings?.["appearance"]; if (props.settingsToReturnLater) { - // although we originally declared it a string, Config-R may return a JSON string or an object - if (typeof props.settingsToReturnLater === "string") { - const parsedSettings = JSON.parse(props.settingsToReturnLater); - appearance = parsedSettings["appearance"]; - } else { - appearance = props.settingsToReturnLater["appearance"]; - } + appearance = props.settingsToReturnLater["appearance"]; } if (!appearance) { // This is a bit arbitrary. It should only apply during early renders. diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx new file mode 100644 index 000000000000..0e4304c28df9 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx @@ -0,0 +1,525 @@ +import * as React from "react"; +import { + ConfigrCustomStringInput, + ConfigrGroup, + ConfigrPage, +} from "@sillsdev/config-r"; +import tinycolor from "tinycolor2"; +import { + ColorDisplayButton, + DialogResult, +} from "../../react_components/color-picking/colorPickerDialog"; +import { BloomPalette } from "../../react_components/color-picking/bloomPalette"; +import { useL10n } from "../../react_components/l10nHooks"; +import { getPageIframeBody } from "../../utils/shared"; + +export type IPageSettings = { + page: { + backgroundColor: string; + pageNumberColor: string; + pageNumberOutlineColor: string; + pageNumberBackgroundColor: string; + }; +}; + +export const getCurrentPageElement = (): HTMLElement => { + const page = getPageIframeBody()?.querySelector( + ".bloom-page", + ) as HTMLElement | null; + if (!page) { + throw new Error( + "PageSettingsConfigrPages could not find .bloom-page in the page iframe", + ); + } + return page; +}; + +const kTransparentCssValue = "transparent"; + +const normalizeToHexOrEmpty = (color: string): string => { + const trimmed = color.trim(); + if (!trimmed) { + return ""; + } + + const parsed = tinycolor(trimmed); + if (!parsed.isValid()) { + return trimmed; + } + + // Treat fully transparent as "not set". + if (parsed.getAlpha() === 0) { + return ""; + } + + if (parsed.getAlpha() < 1) { + return parsed.toHex8String().toUpperCase(); + } + + return parsed.toHexString().toUpperCase(); +}; + +const normalizeToHexOrTransparentOrEmpty = (color: string): string => { + const trimmed = color.trim(); + if (!trimmed) { + return ""; + } + + const parsed = tinycolor(trimmed); + if (!parsed.isValid()) { + return trimmed; + } + + if (parsed.getAlpha() === 0) { + return kTransparentCssValue; + } + + if (parsed.getAlpha() < 1) { + return parsed.toHex8String().toUpperCase(); + } + + return parsed.toHexString().toUpperCase(); +}; + +const getComputedStyleForPage = (page: HTMLElement): CSSStyleDeclaration => { + const view = page.ownerDocument.defaultView; + if (view) { + return view.getComputedStyle(page); + } + return getComputedStyle(page); +}; + +const getCurrentPageBackgroundColor = (): string => { + const page = getCurrentPageElement(); + const computedPage = getComputedStyleForPage(page); + + const inlineMarginBox = normalizeToHexOrEmpty( + page.style.getPropertyValue("--marginBox-background-color"), + ); + if (inlineMarginBox) return inlineMarginBox; + + const inline = normalizeToHexOrEmpty( + page.style.getPropertyValue("--page-background-color"), + ); + if (inline) return inline; + + const computedMarginBoxVariable = normalizeToHexOrEmpty( + computedPage.getPropertyValue("--marginBox-background-color"), + ); + if (computedMarginBoxVariable) return computedMarginBoxVariable; + + const computedVariable = normalizeToHexOrEmpty( + computedPage.getPropertyValue("--page-background-color"), + ); + if (computedVariable) return computedVariable; + + const marginBox = page.querySelector(".marginBox") as HTMLElement | null; + if (marginBox) { + const computedMarginBoxBackground = normalizeToHexOrEmpty( + getComputedStyleForPage(marginBox).backgroundColor, + ); + if (computedMarginBoxBackground) return computedMarginBoxBackground; + } + + const computedBackground = normalizeToHexOrEmpty( + computedPage.backgroundColor, + ); + return computedBackground || "#FFFFFF"; +}; + +const setOrRemoveCustomProperty = ( + style: CSSStyleDeclaration, + propertyName: string, + value: string, +): void => { + const normalized = normalizeToHexOrEmpty(value); + if (normalized) { + style.setProperty(propertyName, normalized); + } else { + style.removeProperty(propertyName); + } +}; + +const setOrRemoveCustomPropertyAllowTransparent = ( + style: CSSStyleDeclaration, + propertyName: string, + value: string, +): void => { + const normalized = normalizeToHexOrTransparentOrEmpty(value); + if (normalized) { + style.setProperty(propertyName, normalized); + } else { + style.removeProperty(propertyName); + } +}; + +const setCurrentPageBackgroundColor = (color: string): void => { + const page = getCurrentPageElement(); + setOrRemoveCustomProperty(page.style, "--page-background-color", color); + setOrRemoveCustomProperty( + page.style, + "--marginBox-background-color", + color, + ); +}; + +const getPageNumberColor = (): string => { + const page = getCurrentPageElement(); + + const inline = normalizeToHexOrEmpty( + page.style.getPropertyValue("--pageNumber-color"), + ); + if (inline) return inline; + + const computed = normalizeToHexOrEmpty( + getComputedStyleForPage(page).getPropertyValue("--pageNumber-color"), + ); + return computed || "#000000"; +}; + +const setPageNumberColor = (color: string): void => { + const page = getCurrentPageElement(); + setOrRemoveCustomProperty(page.style, "--pageNumber-color", color); +}; + +const getPageNumberOutlineColor = (): string => { + const page = getCurrentPageElement(); + + const inline = normalizeToHexOrTransparentOrEmpty( + page.style.getPropertyValue("--pageNumber-outline-color"), + ); + if (inline) return inline; + + const computed = normalizeToHexOrTransparentOrEmpty( + getComputedStyleForPage(page).getPropertyValue( + "--pageNumber-outline-color", + ), + ); + return computed || "#FFFFFF"; +}; + +const setPageNumberOutlineColor = (color: string): void => { + const page = getCurrentPageElement(); + setOrRemoveCustomPropertyAllowTransparent( + page.style, + "--pageNumber-outline-color", + color, + ); +}; + +const getPageNumberBackgroundColor = (): string => { + const page = getCurrentPageElement(); + + const inline = normalizeToHexOrTransparentOrEmpty( + page.style.getPropertyValue("--pageNumber-background-color"), + ); + if (inline) return inline; + + return kTransparentCssValue; +}; + +const setPageNumberBackgroundColor = (color: string): void => { + const page = getCurrentPageElement(); + setOrRemoveCustomPropertyAllowTransparent( + page.style, + "--pageNumber-background-color", + color, + ); +}; + +export const getCurrentPageSettings = (): IPageSettings => { + return { + page: { + backgroundColor: getCurrentPageBackgroundColor(), + pageNumberColor: getPageNumberColor(), + pageNumberOutlineColor: getPageNumberOutlineColor(), + pageNumberBackgroundColor: getPageNumberBackgroundColor(), + }, + }; +}; + +export const applyPageSettings = (settings: IPageSettings): void => { + setCurrentPageBackgroundColor(settings.page.backgroundColor); + setPageNumberColor(settings.page.pageNumberColor); + setPageNumberOutlineColor(settings.page.pageNumberOutlineColor); + setPageNumberBackgroundColor(settings.page.pageNumberBackgroundColor); +}; + +export const parsePageSettingsFromConfigrValue = ( + value: unknown, +): IPageSettings => { + if (typeof value !== "object" || !value) { + throw new Error("Page settings are not an object"); + } + const parsedRecord = value as Record; + const pageValues = parsedRecord["page"]; + + if (typeof pageValues !== "object" || !pageValues) { + throw new Error("Page settings are missing the page object"); + } + + const pageRecord = pageValues as Record; + + const backgroundColor = pageRecord["backgroundColor"]; + const pageNumberColor = pageRecord["pageNumberColor"]; + const pageNumberOutlineColor = pageRecord["pageNumberOutlineColor"]; + const pageNumberBackgroundColor = pageRecord["pageNumberBackgroundColor"]; + + if ( + typeof backgroundColor !== "string" || + typeof pageNumberColor !== "string" || + typeof pageNumberOutlineColor !== "string" || + typeof pageNumberBackgroundColor !== "string" + ) { + throw new Error("Page settings are missing one or more color values"); + } + + return { + page: { + backgroundColor, + pageNumberColor, + pageNumberOutlineColor, + pageNumberBackgroundColor, + }, + }; +}; + +export const arePageSettingsEquivalent = ( + first: IPageSettings, + second: IPageSettings, +): boolean => { + return ( + normalizeToHexOrEmpty(first.page.backgroundColor) === + normalizeToHexOrEmpty(second.page.backgroundColor) && + normalizeToHexOrEmpty(first.page.pageNumberColor) === + normalizeToHexOrEmpty(second.page.pageNumberColor) && + normalizeToHexOrTransparentOrEmpty( + first.page.pageNumberOutlineColor, + ) === + normalizeToHexOrTransparentOrEmpty( + second.page.pageNumberOutlineColor, + ) && + normalizeToHexOrTransparentOrEmpty( + first.page.pageNumberBackgroundColor, + ) === + normalizeToHexOrTransparentOrEmpty( + second.page.pageNumberBackgroundColor, + ) + ); +}; + +type IConfigrColorPickerControlProps = { + value: string; + disabled?: boolean; + onChange: (value: string) => void; +}; + +const ConfigrColorPickerControl: React.FunctionComponent< + IConfigrColorPickerControlProps & { + localizedTitle: string; + transparency: boolean; + palette: BloomPalette; + emptyValueDisplayColor?: string; + onColorPickerVisibilityChanged?: (open: boolean) => void; + } +> = (props) => { + const initialColor = props.value || props.emptyValueDisplayColor; + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + onChange={(newColor) => props.onChange(newColor)} + /> + ); +}; + +const PageSettingsConfigrColorInput: React.FunctionComponent<{ + label: string; + path: string; + description?: string; + localizedTitle: string; + transparency: boolean; + palette: BloomPalette; + emptyValueDisplayColor?: string; + disabled?: boolean; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const colorControl = React.useCallback( + (pickerProps: IConfigrColorPickerControlProps) => ( + + ), + [ + props.emptyValueDisplayColor, + props.localizedTitle, + props.onColorPickerVisibilityChanged, + props.palette, + props.transparency, + ], + ); + + return ( + + ); +}; + +const PageConfigrInputs: React.FunctionComponent<{ + disabled?: boolean; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const backgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + + return ( + + ); +}; + +/* + * BL-15642: hide the page number color group for now. + * We could add this back in the future, perhaps as a book settings feature + * instead of a page settings feature. + */ +// const PageNumberConfigrInputs: React.FunctionComponent<{ +// disabled?: boolean; +// onColorPickerVisibilityChanged?: (open: boolean) => void; +// }> = (props) => { +// const colorLabel = useL10n("Color", "Common.Color"); +// const outlineColorLabel = useL10n( +// "Outline Color", +// "PageSettings.OutlineColor", +// ); +// const outlineColorDescription = useL10n( +// "Use an outline color when the page number needs more contrast against the page.", +// "PageSettings.PageNumberOutlineColor.Description", +// ); +// const backgroundColorLabel = useL10n( +// "Background Color", +// "Common.BackgroundColor", +// ); +// const backgroundColorDescription = useL10n( +// "Use a page number background color when the theme puts the number inside a shape, for example a circle, and you want to specify the color of that shape.", +// "PageSettings.PageNumberBackgroundColor.Description", +// ); +// +// return ( +// <> +// +// +// +// +// ); +// }; + +export type IPageSettingsAreaDefinition = { + label: string; + pageKey: string; + content: string; + pages: React.ReactElement[]; +}; + +export const usePageSettingsAreaDefinition = (props: { + onColorPickerVisibilityChanged?: (open: boolean) => void; +}): IPageSettingsAreaDefinition => { + const pageAreaLabel = useL10n("Page", "BookAndPageSettings.PageArea"); + const colorsPageLabel = useL10n("Colors", "BookAndPageSettings.Colors"); + const pageAreaDescription = useL10n( + "Page settings apply to the current page.", + "BookAndPageSettings.PageArea.Description", + ); + + return { + label: pageAreaLabel, + pageKey: "pageArea", + content: pageAreaDescription, + pages: [ + + + + + {/* + BL-15642: hide the page number color group for now. + We could add this back in the future, perhaps as a book + settings feature instead of a page settings feature. + */} + , + ], + }; +}; diff --git a/src/BloomBrowserUI/bookEdit/bookSettings/StyleAndFontTable.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/StyleAndFontTable.tsx similarity index 100% rename from src/BloomBrowserUI/bookEdit/bookSettings/StyleAndFontTable.tsx rename to src/BloomBrowserUI/bookEdit/bookAndPageSettings/StyleAndFontTable.tsx diff --git a/src/BloomBrowserUI/bookEdit/bookSettings/appearanceThemeUtils.ts b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/appearanceThemeUtils.ts similarity index 100% rename from src/BloomBrowserUI/bookEdit/bookSettings/appearanceThemeUtils.ts rename to src/BloomBrowserUI/bookEdit/bookAndPageSettings/appearanceThemeUtils.ts diff --git a/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx deleted file mode 100644 index 270213542984..000000000000 --- a/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx +++ /dev/null @@ -1,1098 +0,0 @@ -import { css } from "@emotion/react"; -import { Slider, Typography } from "@mui/material"; -import { - ConfigrPane, - ConfigrPage, - ConfigrGroup, - ConfigrStatic, - ConfigrCustomStringInput, - ConfigrCustomObjectInput, - ConfigrBoolean, - ConfigrSelect, -} from "@sillsdev/config-r"; -import * as React from "react"; -import { kBloomBlue, lightTheme } from "../../bloomMaterialUITheme"; -import { ThemeProvider } from "@mui/material/styles"; -import { - BloomDialog, - DialogMiddle, - DialogBottomButtons, - DialogTitle, -} from "../../react_components/BloomDialog/BloomDialog"; -import { useSetupBloomDialog } from "../../react_components/BloomDialog/BloomDialogPlumbing"; -import { - DialogCancelButton, - DialogOkButton, -} from "../../react_components/BloomDialog/commonDialogComponents"; -import { BloomPalette } from "../../react_components/color-picking/bloomPalette"; -import { - ColorDisplayButton, - DialogResult, -} from "../../react_components/color-picking/colorPickerDialog"; -import { - post, - postJson, - useApiBoolean, - useApiObject, - useApiStringState, -} from "../../utils/bloomApi"; -import { ShowEditViewDialog } from "../workspaceRoot"; -import { useL10n } from "../../react_components/l10nHooks"; -import { Div, P } from "../../react_components/l10nComponents"; -import { NoteBox, WarningBox } from "../../react_components/boxes"; -import { default as TrashIcon } from "@mui/icons-material/Delete"; -import { PWithLink } from "../../react_components/pWithLink"; -import { FieldVisibilityGroup } from "./FieldVisibilityGroup"; -import { StyleAndFontTable } from "./StyleAndFontTable"; -import { BloomSubscriptionIndicatorIconAndText } from "../../react_components/requiresSubscription"; -import { useGetFeatureStatus } from "../../react_components/featureStatus"; -import { isLegacyThemeName } from "./appearanceThemeUtils"; - -let isOpenAlready = false; - -type IPageStyle = { label: string; value: string }; -type IPageStyles = Array; -type IAppearanceUIOptions = { - firstPossiblyLegacyCss?: string; - migratedTheme?: string; - themeNames: IPageStyles; -}; - -// Stuff we find in the appearance property of the object we get from the book/settings api. -// Not yet complete -export interface IAppearanceSettings { - cssThemeName: string; -} - -// Stuff we get from the book/settings api. -// Not yet complete -export interface IBookSettings { - appearance?: IAppearanceSettings; - firstPossiblyLegacyCss?: string; -} - -// Stuff we get from the book/settings/overrides api. -// The branding and xmatter objects contain the corresponding settings, -// using the same keys as appearance.json. Currently the values are all -// booleans. -interface IOverrideInformation { - branding: object; - xmatter: object; - brandingName: string; - xmatterName: string; -} - -// Should stay in sync with AppearanceSettings.PageNumberPosition -enum PageNumberPosition { - Automatic = "automatic", - Left = "left", - Center = "center", - Right = "right", - Hidden = "hidden", -} - -export const BookSettingsDialog: React.FunctionComponent<{ - initiallySelectedGroupIndex?: number; -}> = (props) => { - const { closeDialog, propsForBloomDialog } = useSetupBloomDialog({ - initiallyOpen: true, - dialogFrameProvidedExternally: false, - }); - - const appearanceUIOptions: IAppearanceUIOptions = - useApiObject( - "book/settings/appearanceUIOptions", - { - themeNames: [], - }, - ); - // If we pass a new default value to useApiObject on every render, it will query the host - // every time and then set the result, which triggers a new render, making an infinite loop. - const defaultOverrides = React.useMemo(() => { - return { - xmatter: {}, - branding: {}, - xmatterName: "", - brandingName: "", - }; - }, []); - - const overrideInformation: IOverrideInformation | undefined = - useApiObject( - "book/settings/overrides", - defaultOverrides, - ); - - const [pageSizeSupportsFullBleed] = useApiBoolean( - "book/settings/pageSizeSupportsFullBleed", - true, - ); - - const xmatterLockedBy = useL10n( - "Locked by {0} Front/Back matter", - "BookSettings.LockedByXMatter", - "", - overrideInformation?.xmatterName, - ); - - const brandingLockedBy = useL10n( - "Locked by {0} Branding", - "BookSettings.LockedByBranding", - "", - overrideInformation?.brandingName, - ); - - const coverLabel = useL10n("Cover", "BookSettings.CoverGroupLabel"); - const contentPagesLabel = useL10n( - "Content Pages", - "BookSettings.ContentPagesGroupLabel", - ); - const printPublishingLabel = useL10n( - "Print Publishing", - "BookSettings.PrintPublishingGroupLabel", - ); - const languagesToShowNormalSubgroupLabel = useL10n( - "Languages to show in normal text boxes", - "BookSettings.NormalTextBoxLangsLabel", - "", - ); - const themeLabel = useL10n("Page Theme", "BookSettings.PageThemeLabel", ""); - const themeDescription = useL10n( - "", // will be translated or the English will come from the xliff - "BookSettings.Theme.Description", - ); - /* can't use this yet. See https://issues.bloomlibrary.org/youtrack/issue/BL-13094/Enable-links-in-Config-r-Descriptions - const pageThemeDescriptionElement = ( - - Page Themes are a bundle of margins, borders, and other page settings. For information about each theme, see [Page Themes Catalog]. - - ); - */ - - const coverBackgroundColorLabel = useL10n( - "Background Color", - "Common.BackgroundColor", - ); - - const whatToShowOnCoverLabel = useL10n( - "Front Cover", - "BookSettings.WhatToShowOnCover", - ); - - const showLanguageNameLabel = useL10n( - "Show Language Name", - "BookSettings.ShowLanguageName", - ); - const showTopicLabel = useL10n("Show Topic", "BookSettings.ShowTopic"); - const showCreditsLabel = useL10n( - "Show Credits", - "BookSettings.ShowCredits", - ); - const _frontAndBackMatterLabel = useL10n( - "Front & Back Matter", - "BookSettings.FrontAndBackMatter", - ); - const pageNumbersLabel = useL10n( - "Page Numbers", - "BookSettings.PageNumbers", - ); - const pageNumberLocationNote = useL10n( - "Note: some Page Themes may not know how to change the location of the Page Number.", - "BookSettings.PageNumberLocationNote", - ); - const pageNumberPositionAutomaticLabel = useL10n( - "(Automatic)", - "BookSettings.PageNumbers.Automatic", - ); - const pageNumberPositionLeftLabel = useL10n( - "Left", - "BookSettings.PageNumbers.Left", - ); - const pageNumberPositionCenterLabel = useL10n( - "Center", - "BookSettings.PageNumbers.Center", - ); - const pageNumberPositionRightLabel = useL10n( - "Right", - "BookSettings.PageNumbers.Right", - ); - const pageNumberPositionHiddenLabel = useL10n( - "Hidden", - "BookSettings.PageNumbers.Hidden", - ); - - const _frontAndBackMatterDescription = useL10n( - "Normally, books use the front & back matter pack that is chosen for the entire collection. Using this setting, you can cause this individual book to use a different one.", - "BookSettings.FrontAndBackMatter.Description", - ); - const resolutionLabel = useL10n("Resolution", "BookSettings.Resolution"); - const bloomPubLabel = useL10n("eBooks", "PublishTab.bloomPUBButton"); // reuse the same string localized for the Publish tab - - const advancedLayoutLabel = useL10n( - "Advanced Layout", - "BookSettings.AdvancedLayoutLabel", - ); - const textPaddingLabel = useL10n( - "Text Padding", - "BookSettings.TopLevelTextPaddingLabel", - ); - const textPaddingDescription = useL10n( - "Smart spacing around text boxes. Works well for simple pages, but may not suit custom layouts.", - "BookSettings.TopLevelTextPadding.Description", - ); - const textPaddingDefaultLabel = useL10n( - "Default (set by Theme)", - "BookSettings.TopLevelTextPadding.DefaultLabel", - ); - const textPadding1emLabel = useL10n( - "1 em (font size)", - "BookSettings.TopLevelTextPadding.1emLabel", - ); - - const gutterLabel = useL10n("Page Gutter", "BookSettings.Gutter.Label"); - const gutterDescription = useL10n( - "Extra space between pages near the book spine. Increase this for books with many pages to ensure text isn't lost in the binding. This gap is applied to each side of the spine.", - "BookSettings.Gutter.Description", - ); - const gutterDefaultLabel = useL10n( - "Default (set by Theme)", - "BookSettings.Gutter.DefaultLabel", - ); - - const coverIsImageLabel = useL10n( - "Fill the front cover with a single image", - "BookSettings.CoverIsImage", - ); - const coverIsImageDescription = useL10n( - "Replace the front cover content with a single full-bleed image. See [Full Page Cover Images](https://docs.bloomlibrary.org/full-page-cover-images) for information on sizing your image to fit.", - "BookSettings.CoverIsImage.Description.V2", - ); - - const fullBleedLabel = useL10n( - "Use full bleed page layout", - "BookSettings.FullBleed", - ); - const fullBleedDescription = useL10n( - "Enable full bleed layout for printing. This turns on the [Print Bleed](https://en.wikipedia.org/wiki/Bleed_%28printing%29) indicators on paper layouts. See [Full Bleed Layout](https://docs.bloomlibrary.org/full-bleed) for more information.", - "BookSettings.FullBleed.Description", - ); - - // This is a helper function to make it easier to pass the override information - function getAdditionalProps(subPath: string): { - path: string; - overrideValue: T; - overrideDescription?: string; - } { - // some properties will be overridden by branding and/or xmatter - const xmatterOverride: T | undefined = - overrideInformation?.xmatter?.[subPath]; - const brandingOverride = overrideInformation?.branding?.[subPath]; - const override = xmatterOverride ?? brandingOverride; - // nb: xmatterOverride can be boolean, hence the need to spell out !==undefined - let description = - xmatterOverride !== undefined ? xmatterLockedBy : undefined; - if (!description) { - // xmatter wins if both are present - description = - brandingOverride !== undefined ? brandingLockedBy : undefined; - } - // make a an object that can be spread as props in any of the Configr controls - return { - path: "appearance." + subPath, - overrideValue: override as T, - // if we're disabling all appearance controls (e.g. because we're in legacy), don't list a second reason for this overload - overrideDescription: appearanceDisabled ? "" : description, - }; - } - - const [settingsString] = useApiStringState( - "book/settings", - "{}", - () => propsForBloomDialog.open, - ); - - const [settings, setSettings] = React.useState( - undefined, - ); - - const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< - string | IBookSettings | undefined - >(undefined); - - const normalizeConfigrSettings = ( - settingsValue: string | IBookSettings | undefined, - ): IBookSettings | undefined => { - if (!settingsValue) { - return undefined; - } - if (typeof settingsValue === "string") { - return JSON.parse(settingsValue) as IBookSettings; - } - return settingsValue; - }; - - const [appearanceDisabled, setAppearanceDisabled] = React.useState(false); - - // We use state here to allow the dialog UI to update without permanently changing the settings - // and getting notified of those changes. The changes are persisted when the user clicks OK - // (except for the button to delete customBookStyles.css, which is done immediately). - // A downside of this is that when we delete customBookStyles.css, we don't know whether - // the result will be no conflicts or that customCollectionStyles.css will now be the - // firstPossiblyLegacyCss. For now it just behaves as if there are now no conflicts. - // One possible approach is to have the server return the new firstPossiblyLegacyCss - // as the result of the deleteCustomBookStyles call. - const [theme, setTheme] = React.useState(""); - const [firstPossiblyLegacyCss, setFirstPossiblyLegacyCss] = - React.useState(""); - const [migratedTheme, setMigratedTheme] = React.useState(""); - - React.useEffect(() => { - if (settingsString === "{}") { - return; // leave settings as undefined - } - if (typeof settingsString === "string") { - setSettings(JSON.parse(settingsString)); - } else { - setSettings(settingsString); - } - }, [settingsString]); - - React.useEffect(() => { - setFirstPossiblyLegacyCss( - appearanceUIOptions?.firstPossiblyLegacyCss ?? "", - ); - setMigratedTheme(appearanceUIOptions?.migratedTheme ?? ""); - }, [appearanceUIOptions]); - - const bookSettingsTitle = useL10n("Book Settings", "BookSettings.Title"); - React.useEffect(() => { - if (settings?.appearance) { - const liveSettings = - normalizeConfigrSettings(settingsToReturnLater) ?? settings; - // when we're in legacy, we're just going to disable all the appearance controls - setAppearanceDisabled( - isLegacyThemeName(liveSettings?.appearance?.cssThemeName), - ); - setTheme(liveSettings?.appearance?.cssThemeName ?? ""); - } - }, [settings, settingsToReturnLater]); - - const deleteCustomBookStyles = () => { - post( - `book/settings/deleteCustomBookStyles?file=${firstPossiblyLegacyCss}`, - ); - setFirstPossiblyLegacyCss(""); - setMigratedTheme(""); - }; - - const tierAllowsFullPageCoverImage = - useGetFeatureStatus("fullPageCoverImage")?.enabled; - - const tierAllowsFullBleed = useGetFeatureStatus("PrintshopReady")?.enabled; - - function saveSettingsAndCloseDialog() { - const settingsToPost = normalizeConfigrSettings(settingsToReturnLater); - if (settingsToPost) { - // If nothing changed, we don't get any...and don't need to make this call. - postJson("book/settings", settingsToPost); - } - isOpenAlready = false; - closeDialog(); - // todo: how do we make the pageThumbnailList reload? It's in a different browser, so - // we can't use a global. It listens to websocket, but we currently can only listen, - // we cannot send. - } - - return ( - { - isOpenAlready = false; - closeDialog(); - }} - draggable={false} - maxWidth={false} - > - - - {settings && ( - { - setSettingsToReturnLater(s); - //setSettings(s); - }} - initiallySelectedTopLevelPageIndex={ - props.initiallySelectedGroupIndex - } - > - - {appearanceDisabled && ( - - -
- The selected page theme does not - support the following settings. -
-
-
- )} - -
- ( - `coverIsImage`, - )} - disabled={ - appearanceDisabled || - !tierAllowsFullPageCoverImage - } - /> -
- -
-
- - - ( - `cover-languageName-show`, - )} - /> - ( - `cover-topic-show`, - )} - /> - ( - `cover-creditsRow-show`, - )} - /> -
- - ( - `cover-background-color`, - )} - /> - - {/* - - - - */} -
- - { - // This group of four possible messages...sometimes none of them shows, so there are five options... - // is very similar to the one in BookInfoIndicator.tsx. If you change one, you may need to change the other. - // In particular, the logic for which to show and the text of the messages should be kept in sync. - // I'm not seeing a clean way to reuse the logic. Some sort of higher-order component might work, - // but I don't think the logic is complex enough to be worth it, when only used in two places. - } - {firstPossiblyLegacyCss.length > 0 && - isLegacyThemeName(theme) && ( - - - - - - )} - {firstPossiblyLegacyCss === - "customBookStyles.css" && - !isLegacyThemeName(theme) && ( - - -
- {migratedTheme ? ( - - ) : ( - - )} -
- deleteCustomBookStyles() - } - > - -
- Delete{" "} - {firstPossiblyLegacyCss} -
-
-
-
-
- )} - {firstPossiblyLegacyCss.length > 0 && - firstPossiblyLegacyCss !== - "customBookStyles.css" && - !isLegacyThemeName(theme) && ( - - - - - - )} - - {/* Wrapping these two in a div prevents Config-R from sticking a divider between them */} -
- { - return { - label: x.label, - value: x.value, - }; - }, - )} - description={themeDescription} - /> - {appearanceDisabled && ( - -
- The selected page theme does not - support the following settings. -
-
- )} -
- ( - `pageNumber-position`, - )} - options={[ - { - label: pageNumberPositionAutomaticLabel, - value: PageNumberPosition.Automatic, - }, - { - label: pageNumberPositionLeftLabel, - value: PageNumberPosition.Left, - }, - { - label: pageNumberPositionCenterLabel, - value: PageNumberPosition.Center, - }, - { - label: pageNumberPositionRightLabel, - value: PageNumberPosition.Right, - }, - { - label: "--", - value: "--", - }, - { - label: pageNumberPositionHiddenLabel, - value: PageNumberPosition.Hidden, - }, - ]} - description={pageNumberLocationNote} - /> -
- - - - - ( - `topLevel-text-padding`, - )} - /> - ( - `page-gutter`, - )} - /> - -
- - -
- ( - `fullBleed`, - )} - disabled={ - !tierAllowsFullBleed || - !pageSizeSupportsFullBleed - } - /> -
- -
-
-
-
- - {/* note that this is used for bloomPUB and ePUB, but we don't have separate settings so we're putting them in bloomPUB and leaving it to c# code to use it for ePUB as well. */} - - - - - - - - -
-

- When you publish a book to the - web or as an ebook, Bloom will - flag any problematic fonts. For - example, we cannot legally host - most Microsoft fonts on - BloomLibrary.org. -

-

- The following table shows where - fonts have been used. -

-
-
- -
-
-
-
- )} -
- - - - -
- ); -}; - -type Resolution = { - maxWidth: number; - maxHeight: number; -}; - -const BloomResolutionSlider: React.FunctionComponent< - React.PropsWithChildren<{ - path: string; - label: string; - }> -> = (props) => { - return ( -
- - control={BloomResolutionSliderInner} - {...props} - > -
- Bloom reduces images to a maximum size to make books easier to - view over poor internet connections and take up less space on - phones. -
-
- ); -}; - -const BloomResolutionSliderInner: React.FunctionComponent<{ - value: Resolution; - onChange: (value: Resolution) => void; -}> = (props) => { - const sizes = [ - { l: "Small", w: 600, h: 600 }, - { l: "HD", w: 1280, h: 720 }, - { l: "Full HD", w: 1920, h: 1080 }, - { l: "4K", w: 3840, h: 2160 }, - ]; - let currentIndex = sizes.findIndex((x) => x.w === props.value.maxWidth); - if (currentIndex === -1) { - currentIndex = 1; // See BL-12803. - } - const current = sizes[currentIndex]; - const currentLabel = useL10n( - current.l, - `BookSettings.eBook.Image.MaxResolution.${current.l}`, - ); - - return ( - -
- {`${currentLabel}`} - { - return `${current.w}x${current.h}`; - }} - onChange={(e, value) => { - props.onChange({ - maxWidth: sizes[value as number].w, - maxHeight: sizes[value as number].h, - }); - }} - valueLabelDisplay="auto" - > -
-
- ); -}; - -export function showBookSettingsDialog(initiallySelectedGroupIndex?: number) { - // once Bloom's tab bar is also in react, it won't be possible - // to open another copy of this without closing it first, but - // for now, we need to prevent that. - if (!isOpenAlready) { - isOpenAlready = true; - ShowEditViewDialog( - , - ); - } -} - -export const MessageUsingLegacyThemeWithIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The {0} stylesheet of this book is incompatible with modern themes. - Bloom is using it because the book is using the Legacy-5-6 theme. - Click [here] for more information. - - ); -}; - -export const MessageUsingMigratedThemeInsteadOfIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( -
- Bloom found a known version of {props.fileName} in this book and - replaced it with a modern theme. You can delete it unless you still - need to publish the book from an earlier version of Bloom. -
- ); -}; - -export const MessageIgnoringIncompatibleCssCanDelete: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The - {props.fileName} stylesheet of this book is incompatible with modern - themes. Bloom is currently ignoring it. If you don't need those - customizations any more, you can delete your - {props.fileName}. Click [here] for more information. - - ); -}; -export const MessageIgnoringIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The {props.fileName} stylesheet of this book is incompatible with - modern themes. Bloom is currently ignoring it. Click [here] for more - information. - - ); -}; - -const ColorPickerForConfigr: React.FunctionComponent<{ - value: string; - disabled: boolean; - onChange: (value: string) => void; -}> = (props) => { - const coverBackgroundColorLabel = useL10n( - "Background Color", - "Common.BackgroundColor", - ); - - return ( - { - if (dialogResult === DialogResult.OK) props.onChange(newColor); - }} - /> - ); -}; diff --git a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less index 308d3e85bd86..c255d42e0b7a 100644 --- a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less +++ b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less @@ -163,9 +163,10 @@ top: @ToggleVerticalOffset; width: 100%; display: flex; - justify-content: end; + justify-content: space-between; box-sizing: border-box; } + .origami-toggle { cursor: pointer; margin-right: 19px; @@ -178,6 +179,39 @@ display: inline; } } +.page-settings-button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + width: auto; + height: 24px; + padding: 0 4px; + margin-right: 8px; + border: none; + background-color: transparent; + cursor: pointer; + color: @bloom-purple; + white-space: nowrap; + font-size: 12px; + line-height: 1; + + &:hover { + opacity: 0.8; + } + + svg { + width: 20px; + height: 20px; + flex-shrink: 0; + } + + .page-settings-button-label { + font-size: 11px; + line-height: 1; + white-space: nowrap; + } +} // here follows the inner workings of the toggle .onoffswitch { diff --git a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx index 4c9ea432382b..7b9c793f3d69 100644 --- a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx @@ -75,6 +75,8 @@ import { wrapWithRequestPageContentDelay } from "./bloomEditing"; import { get, post, useApiObject } from "../../utils/bloomApi"; import { ILanguageNameValues } from "../bookSettings/FieldVisibilityGroup"; import OverflowChecker from "../OverflowChecker/OverflowChecker"; +import { getString, post, useApiObject } from "../../utils/bloomApi"; +import { ILanguageNameValues } from "../bookAndPageSettings/FieldVisibilityGroup"; interface IMenuItemWithSubmenu extends ILocalizableMenuItemProps { subMenu?: ILocalizableMenuItemProps[]; diff --git a/src/BloomBrowserUI/bookEdit/js/origami.ts b/src/BloomBrowserUI/bookEdit/js/origami.ts index 6da140e5121f..b7115572a565 100644 --- a/src/BloomBrowserUI/bookEdit/js/origami.ts +++ b/src/BloomBrowserUI/bookEdit/js/origami.ts @@ -1,5 +1,3 @@ -// not yet: neither bloomEditing nor this is yet a module import {SetupImage} from './bloomEditing'; -/// import { SetupImage } from "./bloomImages"; import { kBloomCanvasClass } from "../toolbox/canvas/canvasElementUtils"; import "../../lib/split-pane/split-pane.js"; @@ -23,47 +21,91 @@ export function setupOrigami() { const isCanvasFeatureEnabled: boolean = canvasFeatureStatus?.enabled || false; const customPages = document.getElementsByClassName("customPage"); - if (customPages.length > 0) { - const width = customPages[0].clientWidth; - const origamiControl = getAbovePageControlContainer() - .append( - createTypeSelectors( - isWidgetFeatureEnabled, - isCanvasFeatureEnabled, - ), - ) - .append(createTextBoxIdentifier()); + const bloomPage = document.getElementsByClassName( + "bloom-page", + )[0] as HTMLElement | undefined; + const pageWidth = bloomPage?.clientWidth; + if (pageWidth !== undefined) { + const showOrigamiControls = customPages.length > 0; + const pageControlContainer = + getAbovePageControlContainer(showOrigamiControls); + + if (showOrigamiControls) { + pageControlContainer + .append( + createTypeSelectors( + isWidgetFeatureEnabled, + isCanvasFeatureEnabled, + ), + ) + .append(createTextBoxIdentifier()); + } + // The order of this is not important in most ways, since it is positioned absolutely. // However, we position the page label, also absolutely, in the same screen area, and // we want it on top of origami control, so that in template pages the user can edit it. // The page label is part of the page, so we want the page to come after the origami control. // (Could also do this with z-order, but I prefer to do what I can by ordering elements, // and save z-order for when it is really needed.) - $("#page-scaling-container").prepend(origamiControl); + $("#page-scaling-container").prepend(pageControlContainer); // The container width is set to 100% in the CSS, but we need to // limit it to no more than the actual width of the page. const toggleContainer = $(".above-page-control-container").get( 0, ); - toggleContainer.style.maxWidth = width + "px"; + if (toggleContainer instanceof HTMLElement) { + toggleContainer.style.maxWidth = pageWidth + "px"; + } } // I'm not clear why the rest of this needs to wait until we have // the two results, but none of the controls shows up if we leave it all // outside the bloomApi functions. $(".origami-toggle .onoffswitch").change(layoutToggleClickHandler); + $(".page-settings-button").click(pageSettingsButtonClickHandler); if ($(".customPage .marginBox.origami-layout-mode").length) { setupLayoutMode(); $("#myonoffswitch").prop("checked", true); } - $(".customPage, .above-page-control-container") - .find("*[data-i18n]") - .localize(); + const localizableElements = $( + ".customPage, .above-page-control-container", + ).find("*[data-i18n]"); + // In some dev/runtime paths the jQuery localize plugin is not loaded. + try { + if (typeof localizableElements.localize === "function") { + localizableElements.localize(); + } + } catch (error) { + console.warn( + "Origami localization failed; continuing with default labels.", + error, + ); + } + + ensurePageSettingsButtonHasIcon(); }); }); } +function ensurePageSettingsButtonHasIcon() { + $(".page-settings-button").each((_index, element) => { + const button = $(element); + const labelText = $.trim(button.text()) || "Page Settings"; + button.empty(); + button.append($(getPageSettingsButtonIconHtml())); + button.append( + $("").text( + labelText, + ), + ); + }); +} + +function getPageSettingsButtonIconHtml(): string { + return ``; +} + export function cleanupOrigami() { // Otherwise, we get a new one each time the page is loaded $(".split-pane-resize-shim").remove(); @@ -338,9 +380,8 @@ function getSplitPaneComponentInner() { return spci; } -function getAbovePageControlContainer(): JQuery { - // for dragActivities we don't want the origami control, but we still make the - // wrapper so that the dragActivity can put a different control in it. +function getAbovePageControlContainer(showOrigamiControls: boolean): JQuery { + // For dragActivities we reserve this wrapper for the game controls. // Note: We also have to disable the Choose Different layout option in // the right click menu, in PageListView.cs if ( @@ -350,9 +391,19 @@ function getAbovePageControlContainer(): JQuery { ) { return $("
"); } + + if (!showOrigamiControls) { + return $( + `
\ +${getPageSettingsButtonHtml()}\ +
`, + ); + } + return $( - "\ + `\
\ +${getPageSettingsButtonHtml()}\
\
Change Layout
\
\ @@ -363,10 +414,19 @@ function getAbovePageControlContainer(): JQuery { \
\
\ -
", +
`, ); } +function getPageSettingsButtonHtml(): string { + return ``; +} + +function pageSettingsButtonClickHandler(e: Event) { + e.preventDefault(); + post("editView/showPageSettingsDialog"); +} + function getButtons() { const buttons = $( "
", diff --git a/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts b/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts index a1d6580db60e..77566eba697f 100644 --- a/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts +++ b/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts @@ -13,9 +13,9 @@ to hide the details so that we can easily change it later. */ -import { IPageFrameExports } from "../editablePage"; -import { IWorkspaceExports } from "../workspaceRoot"; -import { IToolboxFrameExports } from "../toolbox/toolboxBootstrap"; +import type { IPageFrameExports } from "../editablePage"; +import type { IWorkspaceExports } from "../workspaceRoot"; +import type { IToolboxFrameExports } from "../toolbox/toolboxBootstrap"; export function getToolboxBundleExports(): IToolboxFrameExports | null { const frameWindow = getFrame("toolbox") as diff --git a/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx b/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx index add78f013d77..ef4e62b9a0f2 100644 --- a/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx +++ b/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx @@ -44,6 +44,10 @@ export const PageThumbnail: React.FunctionComponent<{ // a fast desktop for a complex page...mainly because of XhtmlToHtml conversion. // So we do it lazily after setting up the initial framework of pages. const requestPage = useCallback(() => { + if (props.page.key === "placeholder") { + pendingPageRequestCount--; + return; + } // We don't want a lot of page requests running at the same time. // There are various limits on simultaneous requests, including // the number of threads in the BloomServer and the number of active diff --git a/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx b/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx index 60351ff3da45..8e977b892b0b 100644 --- a/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx +++ b/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx @@ -78,6 +78,16 @@ interface IContextMenuPoint { pageId: string; } +const normalizeBookDisplayAttributes = ( + attributes: Record, +): Record => { + const normalized: Record = {}; + Object.entries(attributes).forEach(([key, value]) => { + normalized[key.startsWith("data-") ? key.toLowerCase() : key] = value; + }); + return normalized; +}; + // This map goes from page ID to a callback that we get from the page thumbnail // which should be called when the main Bloom program informs us that // the thumbnail needs to be updated. @@ -107,6 +117,11 @@ const PageList: React.FunctionComponent<{ pageLayout: string }> = (props) => { "pageList/bookAttributesThatMayAffectDisplay", {}, ); + const normalizedBookDisplayAttributes = React.useMemo( + () => + normalizeBookDisplayAttributes(bookAttributesThatMayAffectDisplay), + [bookAttributesThatMayAffectDisplay], + ); const pageMenuDefinition: IPageMenuItem[] = [ { @@ -464,10 +479,7 @@ const PageList: React.FunctionComponent<{ pageLayout: string }> = (props) => { }; return ( -
+
= 50) { + throw new Error("Toolbox accordion did not initialize."); + } + window.setTimeout(() => setCurrentTool(toolID, retryCount + 1), 0); + return; + } const accordionHeaders = toolbox.find("> h3"); if (toolID) { diff --git a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts index ea155a9afa4e..54de4ce564f7 100644 --- a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts +++ b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts @@ -22,6 +22,7 @@ export interface IWorkspaceExports { task: (toolboxFrameExports: IToolboxFrameExports) => unknown, ); getModalDialogContainer(): HTMLElement | null; + ShowEditViewDialog(dialog: FunctionComponentElement): void; showConfirmDialog(props: IConfirmDialogProps): void; showColorPickerDialog(props: IColorPickerDialogProps): void; hideColorPickerDialog(): void; @@ -62,7 +63,7 @@ import { showPageChooserDialog } from "../pageChooser/PageChooserDialog"; export { showPageChooserDialog }; import "../lib/errorHandler"; -import { showBookSettingsDialog } from "./bookSettings/BookSettingsDialog"; +import { showBookSettingsDialog } from "./bookAndPageSettings/BookAndPageSettingsDialog"; export { showBookSettingsDialog }; import { showRegistrationDialogForEditTab } from "../react_components/registration/registrationDialog"; export { showRegistrationDialogForEditTab as showRegistrationDialog }; @@ -275,15 +276,23 @@ export function showEditViewTopicChooserDialog() { showTopicChooserDialog(); } export function showEditViewBookSettingsDialog( - initiallySelectedGroupIndex?: number, + initiallySelectedPageKey?: string, ) { - showBookSettingsDialog(initiallySelectedGroupIndex); + showBookSettingsDialog(initiallySelectedPageKey); } export function showAboutDialogFromWorkspaceRoot() { showAboutDialog(); } +export function showEditViewPageSettingsDialog() { + showBookSettingsDialog("colors"); +} + +export function showAboutDialogInEditTab() { + showAboutDialog(); +} + export function showRequiresSubscriptionDialog(featureName: string): void { showRequiresSubscriptionDialogInEditView(featureName); } @@ -374,6 +383,8 @@ interface WorkspaceBundleApi { showEditViewTopicChooserDialog: typeof showEditViewTopicChooserDialog; showEditViewBookSettingsDialog: typeof showEditViewBookSettingsDialog; showAboutDialogFromWorkspaceRoot: typeof showAboutDialogFromWorkspaceRoot; + showEditViewPageSettingsDialog: typeof showEditViewPageSettingsDialog; + showAboutDialogInEditTab: typeof showAboutDialogInEditTab; showRequiresSubscriptionDialog: typeof showRequiresSubscriptionDialog; showRegistrationDialogFromWorkspaceRoot: typeof showRegistrationDialogFromWorkspaceRoot; showAdjustTimingsDialogFromWorkspaceRoot: typeof showAdjustTimingsDialogFromWorkspaceRoot; @@ -415,6 +426,8 @@ window.workspaceBundle = { showEditViewTopicChooserDialog, showEditViewBookSettingsDialog, showAboutDialogFromWorkspaceRoot, + showEditViewPageSettingsDialog, + showAboutDialogInEditTab, showRequiresSubscriptionDialog, showRegistrationDialogFromWorkspaceRoot, showAdjustTimingsDialogFromWorkspaceRoot: diff --git a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx index 9a313beca1c8..d44c69b4f4c7 100644 --- a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx +++ b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx @@ -1,6 +1,7 @@ import { css } from "@emotion/react"; import * as React from "react"; import { + ConfigrValues, ConfigrGroup, ConfigrPage, ConfigrPane, @@ -27,7 +28,7 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { propsForBloomDialog, } = useEventLaunchedBloomDialog("CollectionSettingsDialog"); - const [settings, setSettings] = React.useState( + const [settings, setSettings] = React.useState( undefined, ); @@ -41,29 +42,17 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { }, [propsForBloomDialog.open]); const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< - string | object | undefined + ConfigrValues | undefined >(undefined); - - const normalizeConfigrSettings = ( - settingsValue: string | object | undefined, - ): object | undefined => { - if (!settingsValue) { - return undefined; - } - if (typeof settingsValue === "string") { - return JSON.parse(settingsValue) as object; - } - return settingsValue; - }; // Parse the settings JSON for Configr's initial values once it arrives. React.useEffect(() => { if (settingsString === "{}") { return; // leave settings as undefined } if (typeof settingsString === "string") { - setSettings(JSON.parse(settingsString)); + setSettings(JSON.parse(settingsString) as ConfigrValues); } else { - setSettings(settingsString); + setSettings(settingsString as ConfigrValues); } }, [settingsString]); @@ -150,11 +139,11 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { { - const settingsToPost = normalizeConfigrSettings( - settingsToReturnLater, - ); - if (settingsToPost) { - postJson("collection/settings", settingsToPost); + if (settingsToReturnLater) { + postJson( + "collection/settings", + settingsToReturnLater, + ); } closeDialog(); }} diff --git a/src/BloomBrowserUI/collectionsTab/BookButton.tsx b/src/BloomBrowserUI/collectionsTab/BookButton.tsx index 02cca9d15dea..f7c0f621067a 100644 --- a/src/BloomBrowserUI/collectionsTab/BookButton.tsx +++ b/src/BloomBrowserUI/collectionsTab/BookButton.tsx @@ -28,7 +28,7 @@ import { makeMenuItems, MenuItemSpec } from "./menuHelpers"; import DeleteIcon from "@mui/icons-material/Delete"; import { useL10n } from "../react_components/l10nHooks"; import SettingsIcon from "@mui/icons-material/Settings"; -import { showBookSettingsDialog } from "../bookEdit/bookSettings/BookSettingsDialog"; +import { showBookSettingsDialog } from "../bookEdit/bookAndPageSettings/BookAndPageSettingsDialog"; import { BookOnBlorgBadge } from "../react_components/BookOnBlorgBadge"; export const bookButtonHeight = 120; diff --git a/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx b/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx index 728ac68ae30f..c7a424a82080 100644 --- a/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx +++ b/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx @@ -294,7 +294,6 @@ export const CollectionsTabBookPane: React.FunctionComponent<{ padding: 10px; background-color: ${kDarkestBackground}; `} - {...props} // allows defining more css rules from container >
void; + onChangeComplete?: (color: ColorResult) => void; // Needed for tooltip on Alpha slider currentOpacity: number; diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx index 11c657ba0eea..7cc5fab676de 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx @@ -1,13 +1,16 @@ import { css } from "@emotion/react"; import * as React from "react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ColorResult, RGBColor } from "react-color"; import BloomSketchPicker from "./bloomSketchPicker"; import ColorSwatch, { IColorInfo } from "./colorSwatch"; import tinycolor from "tinycolor2"; import { HexColorInput } from "./hexColorInput"; import { useL10n } from "../l10nHooks"; -import { Typography } from "@mui/material"; +import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; +import ColorizeIcon from "@mui/icons-material/Colorize"; +import { getColorInfoFromSpecialNameOrColorString } from "./bloomPalette"; // We are combining parts of the 'react-color' component set with our own list of swatches. // The reason for using our own swatches is so we can support swatches with gradients and alpha. @@ -15,18 +18,127 @@ interface IColorPickerProps { transparency?: boolean; noGradientSwatches?: boolean; onChange: (color: IColorInfo) => void; + onChangeComplete?: (color: IColorInfo) => void; currentColor: IColorInfo; swatchColors: IColorInfo[]; includeDefault?: boolean; onDefaultClick?: () => void; defaultButtonLabel?: string; + onEyedropperActiveChange?: (active: boolean) => void; + eyedropperBackdropSelector?: string; //defaultColor?: IColorInfo; will eventually need this } +type EyeDropperResult = { sRGBHex: string }; +type EyeDropper = { open: () => Promise }; +type EyeDropperConstructor = { new (): EyeDropper }; + +const getEyeDropperConstructor = (): EyeDropperConstructor | undefined => { + let iframeWindow: + | (Window & { EyeDropper?: EyeDropperConstructor }) + | null + | undefined; + try { + const iframe = parent.window.document.getElementById( + "page", + ) as HTMLIFrameElement | null; + iframeWindow = iframe?.contentWindow as + | (Window & { EyeDropper?: EyeDropperConstructor }) + | null; + } catch { + iframeWindow = undefined; + } + const topWindow = window as Window & { EyeDropper?: EyeDropperConstructor }; + return iframeWindow?.EyeDropper ?? topWindow.EyeDropper; +}; + +const kEyedropperBackdropStyleId = "bloom-eyedropper-backdrop-style"; +const defaultEyedropperBackdropSelector = ".MuiBackdrop-root"; + +const setEyedropperBackdropTransparent = ( + selector: string | undefined, + enabled: boolean, +): void => { + const resolvedSelector = selector ?? defaultEyedropperBackdropSelector; + if (!resolvedSelector) { + return; + } + + const existing = document.getElementById( + kEyedropperBackdropStyleId, + ) as HTMLStyleElement | null; + + if (enabled) { + if (existing && existing.textContent?.includes(resolvedSelector)) { + return; + } + const style = existing ?? document.createElement("style"); + style.id = kEyedropperBackdropStyleId; + style.textContent = ` + ${resolvedSelector} { + background-color: transparent !important; + } + `; + if (!existing) { + document.head.appendChild(style); + } + } else if (existing) { + existing.remove(); + } +}; + +const setPageScalingDisabled = (disabled: boolean): (() => void) => { + if (!disabled) { + return () => {}; + } + + // Bloom applies page zoom using a transform on this element (see editViewFrame.ts setZoom()). + // WebView2's EyeDropper sampling can be offset when the page content is transformed. + const iframe = parent.window.document.getElementById( + "page", + ) as HTMLIFrameElement | null; + const iframeDoc = iframe?.contentWindow?.document; + const container = iframeDoc?.getElementById( + "page-scaling-container", + ) as HTMLElement | null; + + if (!container) { + return () => {}; + } + + const previousTransform = container.style.transform; + const previousWidth = container.style.width; + const previousTransformOrigin = container.style.transformOrigin; + + container.style.transform = ""; + container.style.width = ""; + container.style.transformOrigin = ""; + + return () => { + container.style.transform = previousTransform; + container.style.width = previousWidth; + container.style.transformOrigin = previousTransformOrigin; + }; +}; + export const ColorPicker: React.FunctionComponent = ( props, ) => { - const [colorChoice, setColorChoice] = useState(props.currentColor); + const [eyedropperActive, setEyedropperActive] = useState(false); + const mountedRef = useRef(true); + const backdropSelector = + props.eyedropperBackdropSelector ?? defaultEyedropperBackdropSelector; + const hasNativeEyedropper = !!getEyeDropperConstructor(); + + // Track mount state so we don't update state after unmount, and to ensure any temporary + // backdrop overrides are removed if the component unmounts while the eyedropper is active. + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + setEyedropperBackdropTransparent(backdropSelector, false); + }; + }, [backdropSelector]); const defaultStyleLabel = useL10n( "Default for style", @@ -34,14 +146,27 @@ export const ColorPicker: React.FunctionComponent = ( ); const defaultButtonLabel = props.defaultButtonLabel ?? defaultStyleLabel; - const changeColor = (swatchColor: IColorInfo) => { - setColorChoice(swatchColor); - props.onChange(swatchColor); + const cloneColor = (color: IColorInfo): IColorInfo => { + return { + ...color, + colors: [...color.colors], + }; + }; + + const changeColor = ( + swatchColor: IColorInfo, + options?: { complete?: boolean }, + ) => { + const clonedColor = cloneColor(swatchColor); + props.onChange(clonedColor); + if (options?.complete) { + props.onChangeComplete?.(clonedColor); + } }; // Handler for when the user clicks on a swatch at the bottom of the picker. const handleSwatchClick = (swatchColor: IColorInfo) => () => { - changeColor(swatchColor); + changeColor(swatchColor, { complete: true }); }; // Handler for when the user clicks/drags in the BloomSketchPicker (Saturation, Hue and Alpha). @@ -50,13 +175,26 @@ export const ColorPicker: React.FunctionComponent = ( changeColor(newColor); }; + const handlePickerChangeComplete = (color: ColorResult) => { + const newColor = getColorInfoFromColorResult(color, ""); + props.onChangeComplete?.(cloneColor(newColor)); + }; + // Handler for when the user changes the hex code value (including pasting). const handleHexCodeChange = (hexColor: string) => { + let colorOnly = hexColor; + let newOpacity = props.currentColor.opacity; + + if (props.transparency && /^#[0-9A-Fa-f]{8}$/.test(hexColor)) { + colorOnly = hexColor.substring(0, 7); + newOpacity = parseInt(hexColor.substring(7, 9), 16) / 255; + } + const newColor = { - colors: [hexColor], - opacity: colorChoice.opacity, // Don't change opacity + colors: [colorOnly], + opacity: newOpacity, }; - changeColor(newColor); + changeColor(newColor, { complete: true }); }; const getColorInfoFromColorResult = ( @@ -83,11 +221,45 @@ export const ColorPicker: React.FunctionComponent = ( }; const getRgbaOfCurrentColor = (): RGBColor => { - const rgbColor = tinycolor(colorChoice.colors[0]).toRgb(); - rgbColor.a = colorChoice.opacity; + const rgbColor = tinycolor(props.currentColor.colors[0]).toRgb(); + rgbColor.a = props.currentColor.opacity; return rgbColor; }; + const handleEyedropperClick = async (): Promise => { + if (eyedropperActive) { + return; + } + + const constructor = getEyeDropperConstructor(); + if (!constructor) { + return; + } + + setEyedropperActive(true); + props.onEyedropperActiveChange?.(true); + setEyedropperBackdropTransparent(backdropSelector, true); + const restorePageScaling = setPageScalingDisabled(true); + try { + const result = await new constructor().open(); + if (result?.sRGBHex) { + changeColor( + getColorInfoFromSpecialNameOrColorString(result.sRGBHex), + { complete: true }, + ); + } + } catch { + // The user can cancel (e.g. Escape), which rejects the promise. + } finally { + restorePageScaling(); + setEyedropperBackdropTransparent(backdropSelector, false); + if (mountedRef.current) { + setEyedropperActive(false); + props.onEyedropperActiveChange?.(false); + } + } + }; + const getColorSwatches = () => ( {props.swatchColors @@ -123,30 +295,53 @@ export const ColorPicker: React.FunctionComponent = ( overflow-x: hidden; `} > + {/* Keep the picker mounted during drags; remounting here breaks slider pointer capture. */}
+ {hasNativeEyedropper && ( + + + + )} diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx index 02504e36698d..c027b329ef39 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx @@ -1,7 +1,7 @@ -import { css } from "@emotion/react"; +import { css, Global } from "@emotion/react"; import * as React from "react"; import * as ReactDOM from "react-dom"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getWorkspaceBundleExports } from "../../bookEdit/js/workspaceFrames"; import { ThemeProvider, StyledEngineProvider } from "@mui/material/styles"; import { lightTheme } from "../../bloomMaterialUITheme"; @@ -27,6 +27,46 @@ import { DialogOkButton, } from "../BloomDialog/commonDialogComponents"; +// These helpers don't depend on component state/props; keeping them outside avoids hook-deps issues. +const willSwatchColorBeFilteredOut = ( + color: IColorInfo, + transparency?: boolean, + noGradientSwatches?: boolean, +): boolean => { + if (!transparency && color.opacity !== 1) { + return true; + } + if (noGradientSwatches && color.colors.length > 1) { + return true; + } + return false; +}; + +const colorCompareFunc = + (colorA: IColorInfo) => + (colorB: IColorInfo): boolean => { + if (colorB.colors.length !== colorA.colors.length) { + return false; // One is a gradient and the other is not. + } + if (colorA.colors.length > 1) { + // In the case of both being gradients, check the second color first. + const gradientAColor2 = tinycolor(colorA.colors[1]); + const gradientBColor2 = tinycolor(colorB.colors[1]); + if (gradientAColor2.toHex() !== gradientBColor2.toHex()) { + return false; + } + } + const gradientAColor1 = tinycolor(colorA.colors[0]); + const gradientBColor1 = tinycolor(colorB.colors[0]); + return ( + gradientAColor1.toHex() === gradientBColor1.toHex() && + colorA.opacity === colorB.opacity + ); + }; + +const isColorInThisArray = (color: IColorInfo, arrayOfColors: IColorInfo[]) => + !!arrayOfColors.find(colorCompareFunc(color)); + export interface IColorPickerDialogProps { open?: boolean; close?: (result: DialogResult) => void; @@ -37,6 +77,7 @@ export interface IColorPickerDialogProps { palette: BloomPalette; isForCanvasElement?: boolean; onChange: (color: IColorInfo) => void; + onChangeComplete?: (color: IColorInfo) => void; onDefaultClick?: () => void; onInputFocus: (input: HTMLElement) => void; includeDefault?: boolean; @@ -52,6 +93,13 @@ const ColorPickerDialog: React.FC = (props) => { props.open === undefined ? true : props.open, ); const [currentColor, setCurrentColor] = useState(props.initialColor); + const [eyedropperActive, setEyedropperActive] = useState(false); + + // Use a content-based key so we don't treat a new object reference with the + // same values as a meaningful change (important for callers that compute + // initialColor inline). + const initialColorKey = + props.initialColor.colors.join("|") + "|" + props.initialColor.opacity; const [swatchColorArray, setSwatchColorArray] = useState( getDefaultColorsFromPalette(props.palette), @@ -60,19 +108,105 @@ const ColorPickerDialog: React.FC = (props) => { externalSetOpen = setOpen; const dlgRef = useRef(null); - function addCustomColors(endpoint: string): void { - get(endpoint, (result) => { - const jsonArray = result.data; - if (!jsonArray.map) { - return; // this means the conversion string -> JSON didn't work. Bad JSON? - } - const customColors = convertJsonColorArrayToColorInfos(jsonArray); - addNewColorsToArrayIfNecessary(customColors); - }); - } + // We come to here on opening to add colors already in the book and we come here on closing to see + // if our new current color needs to be added to our array. + // Enhance: What if the number of distinct colors already used in the book that we get back, plus the number + // of other default colors is more than will fit in our array (current 21)? When we get colors from the book, + // we should maybe start with the current page, to give them a better chance of being included in the picker. + const addNewColorsToArrayIfNecessary = useCallback( + (newColors: IColorInfo[]) => { + // Every time we reference the current swatchColorArray inside + // this setter, we must use previousSwatchColorArray. + // Otherwise, we add to a stale array. + setSwatchColorArray((previousSwatchColorArray) => { + const newColorsAdded: IColorInfo[] = []; + const lengthBefore = previousSwatchColorArray.length; + let numberToDelete = 0; + // CustomColorPicker is going to filter these colors out anyway. + let numberToSkip = previousSwatchColorArray.filter((color) => + willSwatchColorBeFilteredOut( + color, + props.transparency, + props.noGradientSwatches, + ), + ).length; + newColors.forEach((newColor) => { + if ( + isColorInThisArray(newColor, previousSwatchColorArray) + ) { + return; // This one is already in our array of swatch colors + } + if (isColorInThisArray(newColor, newColorsAdded)) { + return; // We don't need to add the same color more than once! + } + // At first I wanted to do this filtering outside the loop, but some of them might be pre-filtered + // by the above two conditions. + if ( + willSwatchColorBeFilteredOut( + newColor, + props.transparency, + props.noGradientSwatches, + ) + ) { + numberToSkip++; + } + if ( + lengthBefore + newColorsAdded.length + 1 > + MAX_SWATCHES + numberToSkip + ) { + numberToDelete++; + } + newColorsAdded.unshift(newColor); // add newColor to the beginning of the array. + }); + const newSwatchColorArray = previousSwatchColorArray.slice(); // Get a new array copy of the old (a different reference) + if (numberToDelete > 0) { + // Remove 'numberToDelete' swatches from oldest custom swatches + const defaultNumber = getDefaultColorsFromPalette( + props.palette, + ).length; + const indexToRemove = + previousSwatchColorArray.length - + defaultNumber - + numberToDelete; + if (indexToRemove >= 0) { + newSwatchColorArray.splice( + indexToRemove, + numberToDelete, + ); + } else { + const excess = indexToRemove * -1; // index went negative; excess is absolute value + newSwatchColorArray.splice(0, numberToDelete - excess); + newColorsAdded.splice( + newColorsAdded.length - excess, + excess, + ); + } + } + const result = newColorsAdded.concat(newSwatchColorArray); + //console.log(result); + return result; + }); + }, + [props.noGradientSwatches, props.palette, props.transparency], + ); + // When the dialog is (re)opened, initialize swatches and currentColor. + // We depend on initialColorKey rather than props.initialColor to avoid resetting the UI + // if a caller passes a new object reference with the same color values on each render. useEffect(() => { if (props.open || open) { + const addCustomColors = (endpoint: string): void => { + get(endpoint, (result) => { + const jsonArray = result.data; + if (!jsonArray.map) { + return; // this means the conversion string -> JSON didn't work. Bad JSON? + } + const customColors = + convertJsonColorArrayToColorInfos(jsonArray); + addNewColorsToArrayIfNecessary(customColors); + }); + }; + setSwatchColorArray(getDefaultColorsFromPalette(props.palette)); addCustomColors( `settings/getCustomPaletteColors?palette=${props.palette}`, @@ -86,13 +220,28 @@ const ColorPickerDialog: React.FC = (props) => { addCustomColors("editView/getColorsUsedInBookCanvasElements"); setCurrentColor(props.initialColor); } - }, [open, props.open]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + open, + props.open, + props.palette, + props.isForCanvasElement, + initialColorKey, + addNewColorsToArrayIfNecessary, + ]); + + // Keep the focus callback current even though we attach DOM listeners only once. + const onInputFocusRef = useRef(props.onInputFocus); + useEffect(() => { + onInputFocusRef.current = props.onInputFocus; + }, [props.onInputFocus]); const focusFunc = (ev: FocusEvent) => { - props.onInputFocus(ev.currentTarget as HTMLElement); + onInputFocusRef.current(ev.currentTarget as HTMLElement); }; - React.useEffect(() => { + // Install focus listeners on inputs so the client can restore focus when canvas updates steal it. + useEffect(() => { const parent = dlgRef.current; if (!parent) { return; @@ -129,7 +278,7 @@ const ColorPickerDialog: React.FC = (props) => { input.removeEventListener("focus", focusFunc), ); }; - }, [dlgRef.current]); + }, []); const convertJsonColorArrayToColorInfos = ( jsonArray: IColorInfo[], @@ -158,6 +307,7 @@ const ColorPickerDialog: React.FC = (props) => { setOpen(false); if (result === DialogResult.Cancel) { props.onChange(props.initialColor); + props.onChangeComplete?.(props.initialColor); setCurrentColor(props.initialColor); } else { if (!isColorInCurrentSwatchColorArray(currentColor)) { @@ -174,119 +324,51 @@ const ColorPickerDialog: React.FC = (props) => { } }; - // We come to here on opening to add colors already in the book and we come here on closing to see - // if our new current color needs to be added to our array. - // Enhance: What if the number of distinct colors already used in the book that we get back, plus the number - // of other default colors is more than will fit in our array (current 21)? When we get colors from the book, - // we should maybe start with the current page, to give them a better chance of being included in the picker. - const addNewColorsToArrayIfNecessary = (newColors: IColorInfo[]) => { - // Every time we reference the current swatchColorArray inside - // this setter, we must use previousSwatchColorArray. - // Otherwise, we add to a stale array. - setSwatchColorArray((previousSwatchColorArray) => { - const newColorsAdded: IColorInfo[] = []; - const lengthBefore = previousSwatchColorArray.length; - let numberToDelete = 0; - // CustomColorPicker is going to filter these colors out anyway. - let numberToSkip = previousSwatchColorArray.filter((color) => - willSwatchColorBeFilteredOut(color), - ).length; - newColors.forEach((newColor) => { - if (isColorInThisArray(newColor, previousSwatchColorArray)) { - return; // This one is already in our array of swatch colors - } - if (isColorInThisArray(newColor, newColorsAdded)) { - return; // We don't need to add the same color more than once! - } - // At first I wanted to do this filtering outside the loop, but some of them might be pre-filtered - // by the above two conditions. - if (willSwatchColorBeFilteredOut(newColor)) { - numberToSkip++; - } - if ( - lengthBefore + newColorsAdded.length + 1 > - MAX_SWATCHES + numberToSkip - ) { - numberToDelete++; - } - newColorsAdded.unshift(newColor); // add newColor to the beginning of the array. - }); - const newSwatchColorArray = swatchColorArray.slice(); // Get a new array copy of the old (a different reference) - if (numberToDelete > 0) { - // Remove 'numberToDelete' swatches from oldest custom swatches - const defaultNumber = getDefaultColorsFromPalette( - props.palette, - ).length; - const indexToRemove = - swatchColorArray.length - defaultNumber - numberToDelete; - if (indexToRemove >= 0) { - newSwatchColorArray.splice(indexToRemove, numberToDelete); - } else { - const excess = indexToRemove * -1; // index went negative; excess is absolute value - newSwatchColorArray.splice(0, numberToDelete - excess); - newColorsAdded.splice( - newColorsAdded.length - excess, - excess, - ); - } - } - const result = newColorsAdded.concat(previousSwatchColorArray); - //console.log(result); - return result; - }); - }; - const isColorInCurrentSwatchColorArray = (color: IColorInfo): boolean => isColorInThisArray(color, swatchColorArray); - const willSwatchColorBeFilteredOut = (color: IColorInfo): boolean => { - if (!props.transparency && color.opacity !== 1) { - return true; - } - if (props.noGradientSwatches && color.colors.length > 1) { - return true; - } - return false; + const handleOnChange = (color: IColorInfo) => { + const clonedColor: IColorInfo = { + ...color, + colors: [...color.colors], + }; + setCurrentColor(clonedColor); + props.onChange(clonedColor); }; - // Use a compare function to see if the color in question matches on already in this list or not. - const isColorInThisArray = ( - color: IColorInfo, - arrayOfColors: IColorInfo[], - ): boolean => !!arrayOfColors.find(colorCompareFunc(color)); - - // Function for comparing a color with an array of colors to see if the color is already - // in the array. We pass this function to .find(). - const colorCompareFunc = - (colorA: IColorInfo) => - (colorB: IColorInfo): boolean => { - if (colorB.colors.length !== colorA.colors.length) { - return false; // One is a gradient and the other is not. - } - if (colorA.colors.length > 1) { - // In the case of both being gradients, check the second color first. - const gradientAColor2 = tinycolor(colorA.colors[1]); - const gradientBColor2 = tinycolor(colorB.colors[1]); - if (gradientAColor2.toHex() !== gradientBColor2.toHex()) { - return false; - } - } - const gradientAColor1 = tinycolor(colorA.colors[0]); - const gradientBColor1 = tinycolor(colorB.colors[0]); - return ( - gradientAColor1.toHex() === gradientBColor1.toHex() && - colorA.opacity === colorB.opacity - ); + const handleOnChangeComplete = (color: IColorInfo) => { + const clonedColor: IColorInfo = { + ...color, + colors: [...color.colors], }; - - const handleOnChange = (color: IColorInfo) => { - setCurrentColor(color); - props.onChange(color); + props.onChangeComplete?.(clonedColor); }; + const dialogOpen = props.open === undefined ? open : props.open; + + // The color picker often opens from inside another dialog. MUI renders that + // outer backdrop outside the nested dialog tree, so we suppress it at the body + // level while keeping this dialog's own invisible backdrop for outside-click handling. + useEffect(() => { + if (!dialogOpen) { + return; + } + document.body.classList.add("bloom-hide-color-picker-backdrop"); + return () => { + document.body.classList.remove("bloom-hide-color-picker-backdrop"); + }; + }, [dialogOpen]); + return ( + = (props) => { padding: 10px 14px 10px 10px; // maintain same spacing all around dialog content and between header/footer } `} - open={props.open === undefined ? open : props.open} + BackdropProps={{ + invisible: true, + }} + slotProps={{ + backdrop: { + invisible: true, + }, + }} + open={dialogOpen} ref={dlgRef} onClose={( _event, reason: "backdropClick" | "escapeKeyDown", ) => { - if (reason === "backdropClick") + if (eyedropperActive) { + return; + } + if (reason === "backdropClick") { onClose(DialogResult.OK); + return; + } if (reason === "escapeKeyDown") onClose(DialogResult.Cancel); }} @@ -325,6 +420,7 @@ const ColorPickerDialog: React.FC = (props) => { = (props) => { includeDefault={props.includeDefault} onDefaultClick={props.onDefaultClick} defaultButtonLabel={props.defaultButtonLabel} + onEyedropperActiveChange={setEyedropperActive} //defaultColor={props.defaultColor} /> @@ -368,13 +465,7 @@ export const showColorPickerDialog = ( }; export const hideColorPickerDialog = () => { - // I'm not sure if this can be falsy, but whereas in the method above we're calling it - // immediately after we render the dialog, which sets it, this gets called long after - // when the tool is closed. Just in case it somehow gets cleared, now or in some future - // version of the code, I decided to leave in the check that CoPilot proposed. - if (externalSetOpen) { - externalSetOpen(false); - } + externalSetOpen(false); }; const doRender = ( @@ -414,12 +505,21 @@ export const showSimpleColorPickerDialog = ( props.initialColor, ), palette: props.palette, - onChange: (color: IColorInfo) => props.onChange(color.colors[0]), + onChange: (color: IColorInfo) => + props.onChange(getColorStringFromColorInfo(color)), onInputFocus: props.onInputFocus, }; showColorPickerDialog(fullProps, props.container); }; +const getColorStringFromColorInfo = (color: IColorInfo): string => { + const firstColor = color.colors[0]; + if (color.opacity === 1) { + return firstColor; + } + return getRgbaColorStringFromColorAndOpacity(firstColor, color.opacity); +}; + export interface IColorDisplayButtonProps { // This is slightly more than an initial color. The button will change color // independently of this to follow the state of the color picker dialog; @@ -430,19 +530,58 @@ export interface IColorDisplayButtonProps { transparency: boolean; width?: number; disabled?: boolean; + deferOnChangeUntilComplete?: boolean; onClose: (result: DialogResult, newColor: string) => void; + onChange?: (newColor: string) => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; palette: BloomPalette; } export const ColorDisplayButton: React.FC = ( props, ) => { + const onColorPickerVisibilityChanged = props.onColorPickerVisibilityChanged; + const deferOnChangeUntilComplete = props.deferOnChangeUntilComplete; + const onChange = props.onChange; const [dialogOpen, setDialogOpen] = useState(false); + const [colorAtDialogOpen, setColorAtDialogOpen] = useState( + props.initialColor, + ); const [currentButtonColor, setCurrentButtonColor] = useState( props.initialColor, ); const widthString = props.width ? `width: ${props.width}px;` : ""; + const initialColorInfo = React.useMemo( + () => + getColorInfoFromSpecialNameOrColorString( + dialogOpen ? colorAtDialogOpen : props.initialColor, + ), + [props.initialColor, dialogOpen, colorAtDialogOpen], + ); + + const handleDialogChange = React.useCallback( + (color: IColorInfo) => { + const newColor = getColorStringFromColorInfo(color); + setCurrentButtonColor(newColor); + if (!deferOnChangeUntilComplete && onChange) { + onChange(newColor); + } + }, + [deferOnChangeUntilComplete, onChange], + ); + + const handleDialogChangeComplete = React.useCallback( + (color: IColorInfo) => { + const newColor = getColorStringFromColorInfo(color); + setCurrentButtonColor(newColor); + if (onChange) { + onChange(newColor); + } + }, + [onChange], + ); + useEffect(() => { if (currentButtonColor !== props.initialColor) { setCurrentButtonColor(props.initialColor); @@ -454,50 +593,113 @@ export const ColorDisplayButton: React.FC = ( // other than a new props value changes it. ) // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.initialColor]); + + useEffect(() => { + return () => { + if (onColorPickerVisibilityChanged) { + onColorPickerVisibilityChanged(false); + } + }; + }, [onColorPickerVisibilityChanged]); + return (
{ - if (props.disabled) return; - setDialogOpen(true); - }} - /> + > +
+
{ + if (props.disabled) return; + if (onColorPickerVisibilityChanged) { + onColorPickerVisibilityChanged(true); + } + setColorAtDialogOpen(props.initialColor); + setDialogOpen(true); + }} + /> +
{ setDialogOpen(false); + if (onColorPickerVisibilityChanged) { + onColorPickerVisibilityChanged(false); + } + if (result === DialogResult.Cancel) { + setCurrentButtonColor(colorAtDialogOpen); + } props.onClose( result, result === DialogResult.OK ? currentButtonColor - : props.initialColor, + : colorAtDialogOpen, ); }} localizedTitle={props.localizedTitle} transparency={props.transparency} palette={props.palette} - initialColor={getColorInfoFromSpecialNameOrColorString( - props.initialColor, - )} + initialColor={initialColorInfo} onInputFocus={() => {}} - onChange={(color: IColorInfo) => - setCurrentButtonColor(color.colors[0]) + onChange={handleDialogChange} + onChangeComplete={ + deferOnChangeUntilComplete + ? handleDialogChangeComplete + : undefined } />
diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts new file mode 100644 index 000000000000..e7fa30666fe6 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts @@ -0,0 +1,124 @@ +import { test, expect } from "../../component-tester/playwrightTest"; +import { setTestComponent } from "../../component-tester/setTestComponent"; + +test.describe("ColorDisplayButton + ColorPickerDialog", () => { + test("single swatch click updates hex input in dialog", async ({ + page, + }) => { + await page.route( + "**/settings/getCustomPaletteColors?palette=*", + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: "[]", + }), + ); + + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + {}, + ); + + await page.getByTestId("color-display-button-swatch").click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + const hexInput = dialog.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111"); + + await dialog.locator(".swatch-row .color-swatch").first().click(); + await expect(hexInput).not.toHaveValue("#111111"); + }); + + test("transparent selection keeps transparency background visible", async ({ + page, + }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + { + initialColor: "transparent", + transparency: true, + }, + ); + + const transparencyBackground = page.getByTestId( + "color-display-button-transparency-background", + ); + await expect(transparencyBackground).toBeVisible({ timeout: 5000 }); + + const backgroundImage = await transparencyBackground.evaluate( + (element) => getComputedStyle(element).backgroundImage, + ); + expect(backgroundImage).not.toBe("none"); + + await expect(page.getByTestId("color-display-button-swatch")).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + }); + + test("deferred change waits until drag completes and cancel restores", async ({ + page, + }) => { + await page.route( + "**/settings/getCustomPaletteColors?palette=*", + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: "[]", + }), + ); + + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + { + initialColor: "#00AA00", + deferOnChangeUntilComplete: true, + }, + ); + + await page.getByTestId("color-display-button-swatch").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.getByTestId("change-count")).toHaveText("0"); + + const hue = page.locator(".hue-horizontal"); + const box = await hue.boundingBox(); + expect(box).not.toBeNull(); + + await page.mouse.move(box!.x + 5, box!.y + box!.height / 2); + await page.mouse.down(); + await page.mouse.move( + box!.x + box!.width * 0.65, + box!.y + box!.height / 2, + { + steps: 8, + }, + ); + + await expect(page.getByTestId("change-count")).toHaveText("0"); + + await page.mouse.up(); + + await expect(page.getByTestId("change-count")).toHaveText("1"); + await expect(page.getByTestId("last-changed-color")).not.toHaveText( + "#00AA00", + ); + + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByTestId("change-count")).toHaveText("2"); + await expect(page.getByTestId("last-changed-color")).toHaveText( + "#00aa00", + ); + await expect(page.getByTestId("close-result")).toHaveText("cancel"); + }); +}); diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx new file mode 100644 index 000000000000..ab8e9bd17f52 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { useState } from "react"; +import { ColorDisplayButton, DialogResult } from "../colorPickerDialog"; +import { BloomPalette } from "../bloomPalette"; + +export const ColorDisplayButtonTestHarness: React.FunctionComponent<{ + initialColor?: string; + transparency?: boolean; + deferOnChangeUntilComplete?: boolean; +}> = (props) => { + const [changeCount, setChangeCount] = useState(0); + const [lastChangedColor, setLastChangedColor] = useState(""); + const [closeResult, setCloseResult] = useState(""); + + return ( +
+
{changeCount}
+
{lastChangedColor}
+
{closeResult}
+ { + setChangeCount((previousCount) => previousCount + 1); + setLastChangedColor(newColor); + }} + onClose={(result: DialogResult, _newColor: string) => { + setCloseResult( + result === DialogResult.OK ? "ok" : "cancel", + ); + }} + /> +
+ ); +}; diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts new file mode 100644 index 000000000000..5af52014ad76 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts @@ -0,0 +1,105 @@ +import { test, expect } from "../../component-tester/playwrightTest"; +import { setTestComponent } from "../../component-tester/setTestComponent"; + +test.describe("ColorPicker", () => { + test("single swatch click updates hex input", async ({ page }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const hexInput = page.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111FF"); + + await page.locator(".swatch-row .color-swatch").first().click(); + await expect(hexInput).toHaveValue("#AA0000FF"); + }); + + test("eyedropper (native) updates hex input", async ({ page }) => { + await page.addInitScript(() => { + ( + window as unknown as Window & { + EyeDropper: { + new (): { open: () => Promise<{ sRGBHex: string }> }; + }; + } + ).EyeDropper = class { + public async open(): Promise<{ sRGBHex: string }> { + return { sRGBHex: "#00AA00" }; + } + }; + }); + + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const hexInput = page.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111FF"); + + await page.locator('button[title="Sample Color"]').click(); + await expect(hexInput).toHaveValue("#00AA00FF"); + }); + + test("external currentColor change updates hex input", async ({ page }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const hexInput = page.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111FF"); + + await page.getByTestId("simulate-external-color").click(); + await expect(hexInput).toHaveValue("#123456FF"); + }); + + test("hue slider supports continuous drag updates", async ({ page }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const swatches = page.locator(".swatch-row .color-swatch"); + await swatches.nth(1).click(); + + const hexInput = page.locator('input[type="text"]'); + const beforeDrag = await hexInput.inputValue(); + + const hue = page.locator(".hue-horizontal"); + const box = await hue.boundingBox(); + expect(box).not.toBeNull(); + + await page.mouse.move(box!.x + 5, box!.y + box!.height / 2); + await page.mouse.down(); + await page.mouse.move( + box!.x + box!.width * 0.35, + box!.y + box!.height / 2, + { + steps: 8, + }, + ); + const duringDrag = await hexInput.inputValue(); + await page.mouse.move( + box!.x + box!.width * 0.7, + box!.y + box!.height / 2, + { + steps: 8, + }, + ); + await page.mouse.up(); + const afterDrag = await hexInput.inputValue(); + + expect(beforeDrag).not.toEqual(duringDrag); + expect(duringDrag).not.toEqual(afterDrag); + }); +}); diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerManualHarness.tsx b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerManualHarness.tsx new file mode 100644 index 000000000000..1c1514f9a0b0 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerManualHarness.tsx @@ -0,0 +1,41 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { useState } from "react"; +import { ColorPicker } from "../colorPicker"; +import { IColorInfo } from "../colorSwatch"; + +export const ColorPickerManualHarness: React.FunctionComponent = () => { + const [currentColor, setCurrentColor] = useState({ + colors: ["#E48C84"], + opacity: 1, + }); + + const swatches: IColorInfo[] = [ + { colors: ["#E48C84"], opacity: 1 }, + { colors: ["#B58B4F"], opacity: 1 }, + { colors: ["#7E5A3C"], opacity: 1 }, + { colors: ["#F0E5D8"], opacity: 1 }, + { colors: ["#D9A6A0"], opacity: 1 }, + { colors: ["#8C6A5A"], opacity: 1 }, + { colors: ["#6D7A7B"], opacity: 1 }, + { colors: ["#F0D36E"], opacity: 1 }, + { colors: ["#85B2C2"], opacity: 1 }, + ]; + + return ( +
+ { + setCurrentColor(color); + }} + /> +
+ ); +}; diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerTestHarness.tsx b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerTestHarness.tsx new file mode 100644 index 000000000000..45092d444ec4 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerTestHarness.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import { useState } from "react"; +import { ColorPicker } from "../colorPicker"; +import { IColorInfo } from "../colorSwatch"; + +export const ColorPickerTestHarness: React.FunctionComponent = () => { + const [currentColor, setCurrentColor] = useState({ + colors: ["#111111"], + opacity: 1, + }); + + const swatches: IColorInfo[] = [ + { colors: ["#AA0000"], opacity: 1 }, + { colors: ["#00AA00"], opacity: 1 }, + { colors: ["#0000AA"], opacity: 1 }, + ]; + + return ( +
+ + +
+ {currentColor.colors.join("|") + "|" + currentColor.opacity} +
+ + setCurrentColor(color)} + /> +
+ ); +}; diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/show-component.uitest.ts b/src/BloomBrowserUI/react_components/color-picking/component-tests/show-component.uitest.ts new file mode 100644 index 000000000000..0d1957b5fded --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/show-component.uitest.ts @@ -0,0 +1,58 @@ +/** + * Interactive manual testing mode using Playwright. + * This opens a visible browser with the component and keeps it open indefinitely + * so you can interact with it manually. + * + * Run with: ./show.sh + */ +import { test } from "../../component-tester/playwrightTest"; +import { setTestComponent } from "../../component-tester/setTestComponent"; + +const includeManualTests = process.env.PLAYWRIGHT_INCLUDE_MANUAL === "1"; +const manualDescribe = includeManualTests ? test.describe : test.describe.skip; + +manualDescribe("Manual Interactive Testing", () => { + test("default", async ({ page }) => { + test.setTimeout(0); + + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerManualHarness", + "ColorPickerManualHarness", + {}, + ); + + await page.waitForEvent("close"); + }); + + test("dialog", async ({ page }) => { + test.setTimeout(0); + + await page.route( + "**/settings/getCustomPaletteColors?palette=*", + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: "[]", + }), + ); + + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + {}, + ); + + await page.waitForEvent("close"); + }); + + test("with-bloom-backend", async ({ page }) => { + test.setTimeout(0); + + await page.goto("/?component=ColorSwatch"); + + await page.waitForEvent("close"); + }); +}); diff --git a/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx b/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx index 73bbdf33699d..3e3a03422b9b 100644 --- a/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx @@ -7,54 +7,92 @@ import { IColorInfo } from "./colorSwatch"; interface IHexColorInputProps { initial: IColorInfo; onChangeComplete: (newValue: string) => void; + includeOpacityChannel?: boolean; } const hashChar = "#"; +const massageColorInput = ( + color: string, + includeOpacityChannel?: boolean, +): string => { + let result = color.toUpperCase(); + result = result.replace(/[^0-9A-F]/g, ""); // eliminate any non-hex characters + result = hashChar + result; // insert hash as the first character + const maxLength = includeOpacityChannel ? 9 : 7; + if (result.length > maxLength) { + result = result.slice(0, maxLength); + } + return result; +}; + +// In general, we want our Hex Color input to reflect the first value in the 'colors' array. +// For our predefined gradients, however, we want the hex input to be empty. +// And for named colors, we need to show the hex equivalent. +const getHexColorValueFromColorInfo = ( + colorInfo: IColorInfo, + includeOpacityChannel?: boolean, +): string => { + // First, our hex value will be empty, if we're dealing with a gradient. + // The massage method below will add a hash character... + if (colorInfo.colors.length > 1) return ""; + const firstColor = colorInfo.colors[0]; + const hexColor = tinycolor(firstColor).toHexString(); + + if (!includeOpacityChannel) { + return hexColor; + } + + const alphaHex = Math.round(colorInfo.opacity * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `${hexColor}${alphaHex}`; +}; + export const HexColorInput: React.FunctionComponent = ( props, ) => { - const [currentColor, setCurrentColor] = useState(""); + const getHexValue = React.useCallback( + (colorInfo: IColorInfo): string => + massageColorInput( + getHexColorValueFromColorInfo( + colorInfo, + props.includeOpacityChannel, + ), + props.includeOpacityChannel, + ), + [props.includeOpacityChannel], + ); - // In general, we want our Hex Color input to reflect the first value in the 'colors' array. - // For our predefined gradients, however, we want the hex input to be empty. - // And for named colors, we need to show the hex equivalent. - const getHexColorValueFromColorInfo = (): string => { - // First, our hex value will be empty, if we're dealing with a gradient. - // The massage method below will add a hash character... - if (props.initial.colors.length > 1) return ""; - const firstColor = props.initial.colors[0]; - if (firstColor[0] === hashChar) return firstColor; - // In some cases we might be dealing with a color word like "black" or "white" or "transparent". - return tinycolor(firstColor).toHexString(); - }; + const [currentColor, setCurrentColor] = useState(() => + getHexValue(props.initial), + ); - const massageColorInput = (color: string): string => { - let result = color.toUpperCase(); - result = result.replace(/[^0-9A-F]/g, ""); // eliminate any non-hex characters - result = hashChar + result; // insert hash as the first character - if (result.length > 7) { - result = result.slice(0, 7); - } - return result; - }; + const initialHexValue = getHexValue(props.initial); + // Keep the displayed hex string in sync when the parent changes the color programmatically + // (e.g. swatch click, eyedropper, or external currentColor updates). useEffect(() => { - setCurrentColor(massageColorInput(getHexColorValueFromColorInfo())); - }, [props.initial.colors]); + setCurrentColor(initialHexValue); + }, [initialHexValue]); const handleInputChange: React.ChangeEventHandler = ( e, ) => { - const result = massageColorInput(e.target.value); + const result = massageColorInput( + e.target.value, + props.includeOpacityChannel, + ); setCurrentColor(result); - if (result.length === 7) { + const completeLength = props.includeOpacityChannel ? 9 : 7; + if (result.length === completeLength) { props.onChangeComplete(result); } }; const borderThickness = 2; - const controlWidth = 60; // This width handles "#DDDDDD" as the maximum width input. + const controlWidth = props.includeOpacityChannel ? 80 : 60; const inputWidth = controlWidth - 2 * borderThickness; return ( diff --git a/src/BloomBrowserUI/react_components/color-picking/show.sh b/src/BloomBrowserUI/react_components/color-picking/show.sh new file mode 100644 index 000000000000..8ec1f479cb96 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/show.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Manual testing for color-picking +# Uses Playwright with full mock support from test-helpers.ts +# Usage: ./show.sh [test-name] + +set -euo pipefail + +COMPONENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPONENT_NAME="$(basename "$COMPONENT_DIR")" + +cd "$COMPONENT_DIR/../component-tester" + +./show-component.sh "$COMPONENT_NAME" "$@" diff --git a/src/BloomBrowserUI/react_components/color-picking/test.sh b/src/BloomBrowserUI/react_components/color-picking/test.sh new file mode 100644 index 000000000000..fddac4e6631c --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/test.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Run automated UI tests for this component +set -e + +script_dir="$(cd "$(dirname "$0")" && pwd)" +cd "$script_dir/../component-tester" + +component_path="../color-picking/component-tests" + +if [ "${1:-}" = "--ui" ]; then + shift + yarn test:ui "$component_path" "$@" +else + yarn test "$component_path" "$@" +fi diff --git a/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts b/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts new file mode 100644 index 000000000000..50c427ffd3eb --- /dev/null +++ b/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts @@ -0,0 +1,50 @@ +export type ElementAttributeMap = { + [attributeName: string]: string; +}; + +export class ElementAttributeSnapshot { + private readonly attributes: ElementAttributeMap; + + private constructor(attributes: ElementAttributeMap) { + this.attributes = attributes; + } + + public static fromElement = ( + element: Element, + ): ElementAttributeSnapshot => { + const snapshot: ElementAttributeMap = {}; + for (let index = 0; index < element.attributes.length; index++) { + const attribute = element.attributes.item(index); + if (attribute) { + snapshot[attribute.name] = attribute.value; + } + } + + return new ElementAttributeSnapshot(snapshot); + }; + + public restoreToElement = (element: Element): void => { + const currentAttributeNames: string[] = []; + for (let index = 0; index < element.attributes.length; index++) { + const attribute = element.attributes.item(index); + if (attribute) { + currentAttributeNames.push(attribute.name); + } + } + + currentAttributeNames.forEach((attributeName) => { + if ( + !Object.prototype.hasOwnProperty.call( + this.attributes, + attributeName, + ) + ) { + element.removeAttribute(attributeName); + } + }); + + Object.keys(this.attributes).forEach((attributeName) => { + element.setAttribute(attributeName, this.attributes[attributeName]); + }); + }; +} diff --git a/src/BloomExe/Book/AppearanceSettings.cs b/src/BloomExe/Book/AppearanceSettings.cs index df99e4bf78c8..fc19c8d8b27c 100644 --- a/src/BloomExe/Book/AppearanceSettings.cs +++ b/src/BloomExe/Book/AppearanceSettings.cs @@ -156,6 +156,8 @@ public string FirstPossiblyOffendingCssFile new CssStringVariableDef("page-split-vertical-gap", "margins"), new CssStringVariableDef("pageNumber-always-left-margin", "page-number"), new CssStringVariableDef("pageNumber-background-color", "page-number"), + new CssStringVariableDef("pageNumber-color", "page-number"), + new CssStringVariableDef("pageNumber-outline-color", "page-number"), new CssStringVariableDef("pageNumber-background-width", "page-number"), new CssStringVariableDef("pageNumber-border-radius", "page-number"), new CssStringVariableDef("pageNumber-bottom", "page-number"), diff --git a/src/BloomExe/Book/HtmlDom.cs b/src/BloomExe/Book/HtmlDom.cs index b2161906ad24..56e2793638f7 100644 --- a/src/BloomExe/Book/HtmlDom.cs +++ b/src/BloomExe/Book/HtmlDom.cs @@ -1886,6 +1886,44 @@ public static void RemoveTemplateEditingMarkup(SafeXmlElement editedPageDiv) public const string musicAttrName = "data-backgroundaudio"; public const string musicVolumeName = musicAttrName + "volume"; + private static readonly string[] kPageStylePropertiesToPersist = + { + "--page-background-color", + "--marginBox-background-color", + "--pageNumber-color", + "--pageNumber-outline-color", + "--pageNumber-background-color", + }; + + private static string GetPersistedPageStyleValue(SafeXmlElement editedPageDiv) + { + var style = editedPageDiv.GetAttribute("style"); + if (string.IsNullOrWhiteSpace(style)) + return string.Empty; + + var persistedStyleSegments = style + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(segment => segment.Trim()) + .Where(segment => !string.IsNullOrEmpty(segment)) + .Where(segment => + { + var colonIndex = segment.IndexOf(':'); + if (colonIndex <= 0) + return false; + + var propertyName = segment.Substring(0, colonIndex).Trim(); + return kPageStylePropertiesToPersist.Contains( + propertyName, + StringComparer.OrdinalIgnoreCase + ); + }) + .ToArray(); + + return persistedStyleSegments.Any() + ? string.Join("; ", persistedStyleSegments) + : string.Empty; + } + public static void ProcessPageAfterEditing( SafeXmlElement destinationPageDiv, SafeXmlElement edittedPageDiv @@ -1921,6 +1959,14 @@ SafeXmlElement edittedPageDiv //html file in a browser. destinationPageDiv.SetAttribute("lang", edittedPageDiv.GetAttribute("lang")); + // Save only the page color custom properties we manage in Page Settings. + // If all are missing, remove any previously-saved page-level custom properties. + var style = GetPersistedPageStyleValue(edittedPageDiv); + if (string.IsNullOrEmpty(style)) + destinationPageDiv.RemoveAttribute("style"); + else + destinationPageDiv.SetAttribute("style", style); + // Copy the two background audio attributes which can be set using the music toolbox. // Ensuring that volume is missing unless the main attribute is non-empty is // currently redundant, everything should work if we just copied all attributes. diff --git a/src/BloomExe/Edit/EditingView.cs b/src/BloomExe/Edit/EditingView.cs index f4af3b15cad7..6d97df576448 100644 --- a/src/BloomExe/Edit/EditingView.cs +++ b/src/BloomExe/Edit/EditingView.cs @@ -1771,10 +1771,10 @@ public void SaveAndOpenBookSettingsDialog() _model.SaveThen( () => { - // Open the book settings dialog to the context-specific group. - var groupIndex = _model.CurrentPage.IsCoverPage ? 0 : 1; + // Open the book settings dialog to the context-specific page. + var pageKey = _model.CurrentPage.IsCoverPage ? "cover" : "contentPages"; RunJavascriptAsync( - $"workspaceBundle.showEditViewBookSettingsDialog({groupIndex});" + $"workspaceBundle.showEditViewBookSettingsDialog('{pageKey}');" ); return _model.CurrentPage.Id; }, @@ -1782,6 +1782,18 @@ public void SaveAndOpenBookSettingsDialog() ); } + public void SaveAndOpenPageSettingsDialog() + { + _model.SaveThen( + () => + { + RunJavascriptAsync("workspaceBundle.showEditViewPageSettingsDialog();"); + return _model.CurrentPage.Id; + }, + () => { } // wrong state, do nothing + ); + } + public async Task AddImageFromUrlAsync(string desiredFileNameWithoutExtension, string url) { using (var client = new System.Net.Http.HttpClient()) diff --git a/src/BloomExe/web/controllers/EditingViewApi.cs b/src/BloomExe/web/controllers/EditingViewApi.cs index 8c1bd25967e8..5d9696a870ea 100644 --- a/src/BloomExe/web/controllers/EditingViewApi.cs +++ b/src/BloomExe/web/controllers/EditingViewApi.cs @@ -131,6 +131,11 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) HandleShowBookSettingsDialog, true ); + apiHandler.RegisterEndpointHandler( + "editView/showPageSettingsDialog", + HandleShowPageSettingsDialog, + true + ); apiHandler.RegisterEndpointHandler( "editView/toggleCustomPageLayout", HandleToggleCustomCover, @@ -277,6 +282,12 @@ private void HandleShowBookSettingsDialog(ApiRequest request) View.SaveAndOpenBookSettingsDialog(); } + private void HandleShowPageSettingsDialog(ApiRequest request) + { + request.PostSucceeded(); + View.SaveAndOpenPageSettingsDialog(); + } + /// /// This one is for the snapping function on dragging origami splitters. /// diff --git a/src/BloomTests/Book/AppearanceSettingsTests.cs b/src/BloomTests/Book/AppearanceSettingsTests.cs index ca37c2f3d118..ca337cb213b4 100644 --- a/src/BloomTests/Book/AppearanceSettingsTests.cs +++ b/src/BloomTests/Book/AppearanceSettingsTests.cs @@ -289,6 +289,25 @@ public void ToCss_ContainsSettingsFromJson() Assert.That(fromSettings, Does.Contain("--cover-languageName-show: none;")); } + [Test] + public void ToCss_ContainsPageNumberColorOverridesFromJson() + { + var settings = new AppearanceSettings(); + settings.UpdateFromJson( + @" +{ + ""cssThemeName"": ""default"", + ""pageNumber-color"": ""#123456"", + ""pageNumber-outline-color"": ""#FFFFFF"" +}" + ); + + var css = settings.ToCss(); + + Assert.That(css, Does.Contain("--pageNumber-color: #123456;")); + Assert.That(css, Does.Contain("--pageNumber-outline-color: #FFFFFF;")); + } + [TestCase(@"""pageNumber-position"": ""automatic""", true)] [TestCase(@"""pageNumber-position"": ""left""", true)] [TestCase(@"""pageNumber-position"": ""center""", true)] diff --git a/src/content/appearanceThemes/appearance-theme-default.css b/src/content/appearanceThemes/appearance-theme-default.css index 532dbed270cb..21f99b589659 100644 --- a/src/content/appearanceThemes/appearance-theme-default.css +++ b/src/content/appearanceThemes/appearance-theme-default.css @@ -39,6 +39,9 @@ --pageNumber-background-width: unset; /* for when we need to have a colored background, e.g. a circle */ /* background-color: value in .numberedPage:after to display the page number */ --pageNumber-background-color: transparent; + /* color: value in .numberedPage:after to display the page number */ + --pageNumber-color: black; + --pageNumber-outline-color: transparent; /* border-radius: value in .numberedPage:after to display the page number */ --pageNumber-border-radius: 0px; /* left: value in .numberedPage.side-left:after to display the page number */ diff --git a/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css b/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css index 36d7d8f3cf4c..aa769b7c0a0d 100644 --- a/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css +++ b/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css @@ -26,16 +26,16 @@ .numberedPage:where([class*="Device"]:not(.bloom-interactive-page)) { --topLevel-text-padding: 0.5em; } - [class*="Device"].numberedPage:not(.bloom-interactive-page) { --pageNumber-extra-height: 0mm !important; /* we put the page number on top of the image so we don't need a margin boost */ + --pageNumber-background-color: #ffffff; /* I'm not clear why this is white, but all I did in this change is to move it so that it can be overridden by page settings */ } [class*="Device"].numberedPage:not(.bloom-interactive-page)::after { --pageNumber-bottom: var(--page-margin-bottom); --pageNumber-top: unset; --pageNumber-font-size: 11pt; --pageNumber-border-radius: 50%; - --pageNumber-background-color: #ffffff; + --pageNumber-background-width: 33px; --pageNumber-always-left-margin: var(--page-margin-left); --pageNumber-right-margin: deliberately-invalid; /* prevents right being set at all. unset does not work. Prevent centering for this layout */ diff --git a/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css b/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css index 2d96796458b6..8828c2daceca 100644 --- a/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css +++ b/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css @@ -17,8 +17,8 @@ Note that hiding the page numbers is done by a setting in appearance.json, not h --page-horizontalSplit-height: 0mm; } -/* The section below controls the page number and the white circle around it. */ -.Device16x9Landscape.numberedPage { +.numberedPage { + --pageNumber-background-color: #ffffff; /* I'm not clear why this is white, but all I did in this change is to move it so that it can be overridden by page settings */ --pageNumber-extra-height: 0mm !important; /* we put the page number on top of the image so we don't need a margin boost */ } .Device16x9Portrait.numberedPage { @@ -41,7 +41,7 @@ Note that hiding the page numbers is done by a setting in appearance.json, not h --pageNumber-font-size: 11pt; border-radius: 50%; - --pageNumber-background-color: #ffffff; + --pageNumber-background-width: 33px; --pageNumber-always-left-margin: var(--page-margin-left); --pageNumber-right-margin: deliberately-invalid; /* prevents right being set at all. unset does not work. Prevent centering for this layout */ diff --git a/src/content/bookLayout/pageNumbers.less b/src/content/bookLayout/pageNumbers.less index 254a9ca3a3e6..d0036cdf84fa 100644 --- a/src/content/bookLayout/pageNumbers.less +++ b/src/content/bookLayout/pageNumbers.less @@ -7,6 +7,7 @@ // themes can override this as needed. If you have reasonable margins, you don't need to add anything to fit in a pageNumber --pageNumber-extra-height: 0mm; // must have units } + .numberedPage { &:after { content: attr(data-page-number); @@ -22,6 +23,10 @@ bottom: var(--pageNumber-bottom); top: var(--pageNumber-top); background-color: var(--pageNumber-background-color); + color: var(--pageNumber-color); + -webkit-text-stroke: 1px var(--pageNumber-outline-color); + paint-order: stroke fill; + border-radius: var(--pageNumber-border-radius); z-index: 1000; // These are needed to get the number centered in a circle. They have diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000000..fb57ccd13afb --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + From 5c50810692136c1ca045be4345346095d71fcaaa Mon Sep 17 00:00:00 2001 From: Hatton Date: Fri, 27 Mar 2026 16:57:52 -0600 Subject: [PATCH 09/65] simplify --- src/BloomBrowserUI/bookEdit/js/origami.ts | 78 ++++++++----------- .../bookEdit/js/pageSettingsButtonIcon.svg | 3 + .../CollectionsTabBookPane.tsx | 1 + src/BloomBrowserUI/custom.d.ts | 5 ++ 4 files changed, 41 insertions(+), 46 deletions(-) create mode 100644 src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg diff --git a/src/BloomBrowserUI/bookEdit/js/origami.ts b/src/BloomBrowserUI/bookEdit/js/origami.ts index b7115572a565..4d0b8d3ab506 100644 --- a/src/BloomBrowserUI/bookEdit/js/origami.ts +++ b/src/BloomBrowserUI/bookEdit/js/origami.ts @@ -6,6 +6,8 @@ import { post, postThatMightNavigate } from "../../utils/bloomApi"; import { theOneCanvasElementManager } from "./CanvasElementManager"; import { getFeatureStatusAsync } from "../../react_components/featureStatus"; import $ from "jquery"; +import "../../lib/jquery.i18n.custom"; +import pageSettingsButtonIconSvg from "./pageSettingsButtonIcon.svg?raw"; import { splitPane } from "../../lib/split-pane/split-pane"; import { kCanvasToolId } from "../toolbox/toolIds"; @@ -68,44 +70,13 @@ export function setupOrigami() { $("#myonoffswitch").prop("checked", true); } - const localizableElements = $( - ".customPage, .above-page-control-container", - ).find("*[data-i18n]"); - // In some dev/runtime paths the jQuery localize plugin is not loaded. - try { - if (typeof localizableElements.localize === "function") { - localizableElements.localize(); - } - } catch (error) { - console.warn( - "Origami localization failed; continuing with default labels.", - error, - ); - } - - ensurePageSettingsButtonHasIcon(); + $(".customPage, .above-page-control-container") + .find("*[data-i18n]") + .localize(); }); }); } -function ensurePageSettingsButtonHasIcon() { - $(".page-settings-button").each((_index, element) => { - const button = $(element); - const labelText = $.trim(button.text()) || "Page Settings"; - button.empty(); - button.append($(getPageSettingsButtonIconHtml())); - button.append( - $("").text( - labelText, - ), - ); - }); -} - -function getPageSettingsButtonIconHtml(): string { - return ``; -} - export function cleanupOrigami() { // Otherwise, we get a new one each time the page is loaded $(".split-pane-resize-shim").remove(); @@ -394,16 +365,15 @@ function getAbovePageControlContainer(showOrigamiControls: boolean): JQuery { if (!showOrigamiControls) { return $( - `
\ -${getPageSettingsButtonHtml()}\ -
`, - ); + "
", + ).append(createPageSettingsButton()); } - return $( - `\ -
\ -${getPageSettingsButtonHtml()}\ + return $("
") + .append(createPageSettingsButton()) + .append( + $( + `\
\
Change Layout
\
\ @@ -413,13 +383,29 @@ ${getPageSettingsButtonHtml()}\ \ \
\ -
\
`, - ); + ), + ); } -function getPageSettingsButtonHtml(): string { - return ``; +function createPageSettingsButton(): JQuery { + return $( + "", + ) + .append(createPageSettingsButtonIcon()) + .append( + $( + "Page Settings", + ), + ); +} + +function createPageSettingsButtonIcon(): SVGSVGElement { + const iconDocument = new DOMParser().parseFromString( + pageSettingsButtonIconSvg, + "image/svg+xml", + ); + return iconDocument.documentElement as unknown as SVGSVGElement; } function pageSettingsButtonClickHandler(e: Event) { diff --git a/src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg b/src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg new file mode 100644 index 000000000000..a54556aba9c8 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg @@ -0,0 +1,3 @@ + diff --git a/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx b/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx index c7a424a82080..728ac68ae30f 100644 --- a/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx +++ b/src/BloomBrowserUI/collectionsTab/collectionsTabBookPane/CollectionsTabBookPane.tsx @@ -294,6 +294,7 @@ export const CollectionsTabBookPane: React.FunctionComponent<{ padding: 10px; background-color: ${kDarkestBackground}; `} + {...props} // allows defining more css rules from container >
Date: Fri, 27 Mar 2026 17:36:55 -0600 Subject: [PATCH 10/65] UseEffect reduction from https://github.com/softaworks/agent-toolkit --- .github/skills/react-useeffect/README.md | 320 ++++++++++++++++++ .github/skills/react-useeffect/SKILL.md | 53 +++ .../skills/react-useeffect/alternatives.md | 258 ++++++++++++++ .../skills/react-useeffect/anti-patterns.md | 290 ++++++++++++++++ src/BloomBrowserUI/AGENTS.md | 33 +- .../BookAndPageSettingsDialog.tsx | 66 ++-- .../collection/CollectionSettingsDialog.tsx | 21 +- .../color-picking/colorPickerDialog.tsx | 20 +- 8 files changed, 967 insertions(+), 94 deletions(-) create mode 100644 .github/skills/react-useeffect/README.md create mode 100644 .github/skills/react-useeffect/SKILL.md create mode 100644 .github/skills/react-useeffect/alternatives.md create mode 100644 .github/skills/react-useeffect/anti-patterns.md diff --git a/.github/skills/react-useeffect/README.md b/.github/skills/react-useeffect/README.md new file mode 100644 index 000000000000..490ad4bae14f --- /dev/null +++ b/.github/skills/react-useeffect/README.md @@ -0,0 +1,320 @@ +# React useEffect Best Practices + +A comprehensive guide teaching when to use `useEffect` in React, and more importantly, when NOT to use it. This skill is based on official React documentation and provides practical alternatives to common useEffect anti-patterns. + +## Purpose + +Effects are an **escape hatch** from React's reactive paradigm. They let you synchronize with external systems like browser APIs, third-party widgets, or network requests. However, many developers overuse Effects for tasks that React handles better through other means. + +This skill helps you: +- Identify when you truly need an Effect vs. when you don't +- Recognize common anti-patterns and their fixes +- Apply better alternatives like `useMemo`, `key` prop, and event handlers +- Write Effects that are clean, maintainable, and free from race conditions + +## When to Use This Skill + +Use this skill when you're: +- Writing or reviewing `useEffect` code +- Using `useState` to store derived values +- Implementing data fetching or subscriptions +- Synchronizing state between components +- Facing bugs with stale data or race conditions +- Wondering if your Effect is necessary + +**Trigger phrases:** +- "Should I use useEffect for this?" +- "How do I fix this useEffect?" +- "My Effect is causing too many re-renders" +- "Data fetching with useEffect" +- "Reset state when props change" +- "Derived state from props" + +## How It Works + +This skill provides guidance through three key resources: + +1. **Quick Reference Table** - Fast lookup for common scenarios with DO/DON'T patterns +2. **Decision Tree** - Visual flowchart to determine the right approach +3. **Detailed Anti-Patterns** - 9 common mistakes with explanations and fixes +4. **Better Alternatives** - 8 proven patterns to replace unnecessary Effects + +The skill teaches you to ask the right questions: +- Is there an external system involved? +- Am I responding to a user event or component appearance? +- Can this value be calculated during render? +- Do I need to reset state when a prop changes? + +## Key Features + +### 1. Quick Reference Guide + +Visual table showing the DO/DON'T for common scenarios: +- Derived state from props/state +- Expensive calculations +- Resetting state on prop change +- User event responses +- Notifying parent components +- Data fetching + +### 2. Decision Tree + +Clear flowchart that guides you from "Need to respond to something?" to the correct solution: +- User interaction → Event handler +- Component appeared → Effect (for external sync/analytics) +- Derived value needed → Calculate during render (+ useMemo if expensive) +- Reset state on prop change → Key prop + +### 3. Anti-Pattern Recognition + +Detailed examples of 9 common mistakes: +1. Redundant state for derived values +2. Filtering/transforming data in Effect +3. Resetting state on prop change +4. Event-specific logic in Effect +5. Chains of Effects +6. Notifying parent via Effect +7. Passing data up to parent +8. Fetching without cleanup (race conditions) +9. App initialization in Effect + +Each anti-pattern includes: +- Bad example with explanation +- Good example with fix +- Why the anti-pattern is problematic + +### 4. Better Alternatives + +8 proven patterns to replace unnecessary Effects: +1. Calculate during render for derived state +2. `useMemo` for expensive calculations +3. `key` prop to reset state +4. Store ID instead of object for stable references +5. Event handlers for user actions +6. `useSyncExternalStore` for external stores +7. Lifting state up for shared state +8. Custom hooks for data fetching with cleanup + +## Usage Examples + +### Example 1: Derived State + +**Bad - Unnecessary Effect:** +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); +} +``` + +**Good - Calculate during render:** +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const fullName = firstName + ' ' + lastName; // Just compute it +} +``` + +### Example 2: Resetting State + +**Bad - Effect to reset:** +```tsx +function ProfilePage({ userId }) { + const [comment, setComment] = useState(''); + + useEffect(() => { + setComment(''); + }, [userId]); +} +``` + +**Good - Key prop:** +```tsx +function ProfilePage({ userId }) { + return ; +} + +function Profile({ userId }) { + const [comment, setComment] = useState(''); // Resets automatically +} +``` + +### Example 3: Data Fetching with Cleanup + +**Bad - Race condition:** +```tsx +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + fetchResults(query).then(json => { + setResults(json); // "hello" response may arrive after "hell" + }); + }, [query]); +} +``` + +**Good - Cleanup flag:** +```tsx +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + let ignore = false; + + fetchResults(query).then(json => { + if (!ignore) setResults(json); + }); + + return () => { ignore = true; }; + }, [query]); +} +``` + +### Example 4: Event Handler Instead of Effect + +**Bad - Effect watching state:** +```tsx +function ProductPage({ product, addToCart }) { + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name}!`); + } + }, [product]); + + function handleBuyClick() { + addToCart(product); + } +} +``` + +**Good - Handle in event:** +```tsx +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + } +} +``` + +## When You DO Need Effects + +Effects are appropriate for: + +- **Synchronizing with external systems** - Browser APIs, third-party widgets, non-React code +- **Subscriptions** - WebSocket connections, global event listeners (prefer `useSyncExternalStore`) +- **Analytics/logging** - Code that needs to run because the component displayed +- **Data fetching** - With proper cleanup (or use your framework's built-in mechanism) + +## When You DON'T Need Effects + +Avoid Effects for: + +1. **Transforming data for rendering** - Calculate at the top level instead +2. **Handling user events** - Use event handlers where you know exactly what happened +3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` +4. **Chaining state updates** - Calculate all next state in the event handler +5. **Notifying parent components** - Call the callback in the same event handler +6. **Resetting state** - Use the `key` prop to create a fresh component instance + +## Best Practices + +### 1. Start Without an Effect + +Before adding an Effect, ask: "Is there an external system involved?" If no, you probably don't need an Effect. + +### 2. Prefer Derived State + +If you can calculate a value from props or state, don't store it in state with an Effect updating it. + +### 3. Use the Right Tool + +- Expensive calculation → `useMemo` +- User interaction → Event handler +- Reset on prop change → `key` prop +- External subscription → `useSyncExternalStore` +- Shared state → Lift state up + +### 4. Always Clean Up + +If your Effect subscribes, fetches, or sets timers, return a cleanup function to prevent memory leaks and race conditions. + +### 5. Avoid Effect Chains + +Multiple Effects triggering each other causes unnecessary re-renders and makes code hard to follow. Calculate everything in one place (usually an event handler). + +### 6. Test in Strict Mode + +React 18+ Strict Mode mounts components twice in development to expose missing cleanup. If your Effect breaks, you need cleanup. + +### 7. Consider Framework Solutions + +For data fetching, prefer your framework's built-in solution (Next.js, Remix) or libraries (React Query, SWR) over manual Effects. + +## Reference Files + +This skill includes three detailed reference documents: + +1. **SKILL.md** - Quick reference table and decision tree +2. **anti-patterns.md** - 9 common mistakes with detailed explanations +3. **alternatives.md** - 8 better alternatives with code examples + +## Common Pitfalls + +### Multiple Re-renders + +**Symptom:** Component re-renders many times in quick succession. + +**Cause:** Effect that sets state based on state it depends on, creating a loop. + +**Fix:** Calculate the final value in an event handler or during render. + +### Stale Data + +**Symptom:** UI shows outdated values briefly before updating. + +**Cause:** Using Effect to update derived state causes an extra render pass. + +**Fix:** Calculate derived values during render instead of in state. + +### Race Conditions + +**Symptom:** Fast typing shows results for old queries after new ones. + +**Cause:** Missing cleanup in data fetching Effect. + +**Fix:** Use cleanup flag (`ignore` variable) or AbortController. + +### Runs Twice in Development + +**Symptom:** Effect runs twice on component mount in development. + +**Cause:** React 18 Strict Mode intentionally mounts components twice to expose bugs. + +**Fix:** Add proper cleanup. If it's app initialization that shouldn't run twice, use a module-level guard. + +## Resources + +This skill is based on: +- [React Official Docs: You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) +- [React Official Docs: Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects) +- [React Official Docs: Lifecycle of Reactive Effects](https://react.dev/learn/lifecycle-of-reactive-effects) + +## Summary + +The golden rule: **Effects are an escape hatch from React.** If you're not synchronizing with an external system, you probably don't need an Effect. + +Before writing `useEffect`, ask yourself: +1. Is this responding to a user interaction? → Use event handler +2. Is this a value I can calculate from props/state? → Calculate during render +3. Is this resetting state when a prop changes? → Use key prop +4. Is this synchronizing with an external system? → Use Effect with cleanup + +Follow these patterns, and your React code will be more maintainable, performant, and bug-free. diff --git a/.github/skills/react-useeffect/SKILL.md b/.github/skills/react-useeffect/SKILL.md new file mode 100644 index 000000000000..d7c6ffb23fe4 --- /dev/null +++ b/.github/skills/react-useeffect/SKILL.md @@ -0,0 +1,53 @@ +--- +name: react-useeffect +description: React useEffect best practices from official docs. Use when writing/reviewing useEffect, useState for derived values, data fetching, or state synchronization. Teaches when NOT to use Effect and better alternatives. +--- + +# You Might Not Need an Effect + +Effects are an **escape hatch** from React. They let you synchronize with external systems. If there is no external system involved, you shouldn't need an Effect. + +## Quick Reference + +| Situation | DON'T | DO | +|-----------|-------|-----| +| Derived state from props/state | `useState` + `useEffect` | Calculate during render | +| Expensive calculations | `useEffect` to cache | `useMemo` | +| Reset state on prop change | `useEffect` with `setState` | `key` prop | +| User event responses | `useEffect` watching state | Event handler directly | +| Notify parent of changes | `useEffect` calling `onChange` | Call in event handler | +| Fetch data | `useEffect` without cleanup | `useEffect` with cleanup OR framework | + +## When You DO Need Effects + +- Synchronizing with **external systems** (non-React widgets, browser APIs) +- **Subscriptions** to external stores (use `useSyncExternalStore` when possible) +- **Analytics/logging** that runs because component displayed +- **Data fetching** with proper cleanup (or use framework's built-in mechanism) + +## When You DON'T Need Effects + +1. **Transforming data for rendering** - Calculate at top level, re-runs automatically +2. **Handling user events** - Use event handlers, you know exactly what happened +3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` +4. **Chaining state updates** - Calculate all next state in the event handler + +## Decision Tree + +``` +Need to respond to something? +├── User interaction (click, submit, drag)? +│ └── Use EVENT HANDLER +├── Component appeared on screen? +│ └── Use EFFECT (external sync, analytics) +├── Props/state changed and need derived value? +│ └── CALCULATE DURING RENDER +│ └── Expensive? Use useMemo +└── Need to reset state when prop changes? + └── Use KEY PROP on component +``` + +## Detailed Guidance + +- [Anti-Patterns](./anti-patterns.md) - Common mistakes with fixes +- [Better Alternatives](./alternatives.md) - useMemo, key prop, lifting state, useSyncExternalStore diff --git a/.github/skills/react-useeffect/alternatives.md b/.github/skills/react-useeffect/alternatives.md new file mode 100644 index 000000000000..791744ab7049 --- /dev/null +++ b/.github/skills/react-useeffect/alternatives.md @@ -0,0 +1,258 @@ +# Better Alternatives to useEffect + +## 1. Calculate During Render (Derived State) + +For values derived from props or state, just compute them: + +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + + // Runs every render - that's fine and intentional + const fullName = firstName + ' ' + lastName; + const isValid = firstName.length > 0 && lastName.length > 0; +} +``` + +**When to use**: The value can be computed from existing props/state. + +--- + +## 2. useMemo for Expensive Calculations + +When computation is expensive, memoize it: + +```tsx +import { useMemo } from 'react'; + +function TodoList({ todos, filter }) { + const visibleTodos = useMemo( + () => getFilteredTodos(todos, filter), + [todos, filter] + ); +} +``` + +**How to know if it's expensive**: +```tsx +console.time('filter'); +const visibleTodos = getFilteredTodos(todos, filter); +console.timeEnd('filter'); +// If > 1ms, consider memoizing +``` + +**Note**: React Compiler can auto-memoize, reducing manual useMemo needs. + +--- + +## 3. Key Prop to Reset State + +To reset ALL state when a prop changes, use key: + +```tsx +// Parent passes userId as key +function ProfilePage({ userId }) { + return ( + + ); +} + +function Profile({ userId }) { + // All state here resets when userId changes + const [comment, setComment] = useState(''); + const [likes, setLikes] = useState([]); +} +``` + +**When to use**: You want a "fresh start" when an identity prop changes. + +--- + +## 4. Store ID Instead of Object + +To preserve selection when list changes: + +```tsx +// BAD: Storing object that needs Effect to "adjust" +function List({ items }) { + const [selection, setSelection] = useState(null); + + useEffect(() => { + setSelection(null); // Reset when items change + }, [items]); +} + +// GOOD: Store ID, derive object +function List({ items }) { + const [selectedId, setSelectedId] = useState(null); + + // Derived - no Effect needed + const selection = items.find(item => item.id === selectedId) ?? null; +} +``` + +**Benefit**: If item with selectedId exists in new list, selection preserved. + +--- + +## 5. Event Handlers for User Actions + +User clicks/submits/drags should be handled in event handlers, not Effects: + +```tsx +// Event handler knows exactly what happened +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + analytics.track('product_added', { id: product.id }); + } + + function handleCheckoutClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + navigateTo('/checkout'); + } +} +``` + +**Shared logic**: Extract a function, call from both handlers: + +```tsx +function buyProduct() { + addToCart(product); + showNotification(`Added ${product.name}!`); +} + +function handleBuyClick() { buyProduct(); } +function handleCheckoutClick() { buyProduct(); navigateTo('/checkout'); } +``` + +--- + +## 6. useSyncExternalStore for External Stores + +For subscribing to external data (browser APIs, third-party stores): + +```tsx +// Instead of manual Effect subscription +function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(true); + + useEffect(() => { + function update() { setIsOnline(navigator.onLine); } + window.addEventListener('online', update); + window.addEventListener('offline', update); + return () => { + window.removeEventListener('online', update); + window.removeEventListener('offline', update); + }; + }, []); + + return isOnline; +} + +// Use purpose-built hook +import { useSyncExternalStore } from 'react'; + +function subscribe(callback) { + window.addEventListener('online', callback); + window.addEventListener('offline', callback); + return () => { + window.removeEventListener('online', callback); + window.removeEventListener('offline', callback); + }; +} + +function useOnlineStatus() { + return useSyncExternalStore( + subscribe, + () => navigator.onLine, // Client value + () => true // Server value (SSR) + ); +} +``` + +--- + +## 7. Lifting State Up + +When two components need synchronized state, lift it to common ancestor: + +```tsx +// Instead of syncing via Effects between siblings +function Parent() { + const [value, setValue] = useState(''); + + return ( + <> + + + + ); +} +``` + +--- + +## 8. Custom Hooks for Data Fetching + +Extract fetch logic with proper cleanup: + +```tsx +function useData(url) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let ignore = false; + setLoading(true); + + fetch(url) + .then(res => res.json()) + .then(json => { + if (!ignore) { + setData(json); + setError(null); + } + }) + .catch(err => { + if (!ignore) setError(err); + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + + return () => { ignore = true; }; + }, [url]); + + return { data, error, loading }; +} + +// Usage +function SearchResults({ query }) { + const { data, error, loading } = useData(`/api/search?q=${query}`); +} +``` + +**Better**: Use framework's data fetching (React Query, SWR, Next.js, etc.) + +--- + +## Summary: When to Use What + +| Need | Solution | +|------|----------| +| Value from props/state | Calculate during render | +| Expensive calculation | `useMemo` | +| Reset all state on prop change | `key` prop | +| Respond to user action | Event handler | +| Sync with external system | `useEffect` with cleanup | +| Subscribe to external store | `useSyncExternalStore` | +| Share state between components | Lift state up | +| Fetch data | Custom hook with cleanup / framework | diff --git a/.github/skills/react-useeffect/anti-patterns.md b/.github/skills/react-useeffect/anti-patterns.md new file mode 100644 index 000000000000..d35151fdc8db --- /dev/null +++ b/.github/skills/react-useeffect/anti-patterns.md @@ -0,0 +1,290 @@ +# useEffect Anti-Patterns + +## 1. Redundant State for Derived Values + +```tsx +// BAD: Extra state + Effect for derived value +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); +} + +// GOOD: Calculate during rendering +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const fullName = firstName + ' ' + lastName; // Just compute it +} +``` + +**Why it's bad**: Causes extra render pass with stale value, then re-renders with updated value. + +--- + +## 2. Filtering/Transforming Data in Effect + +```tsx +// BAD: Effect to filter list +function TodoList({ todos, filter }) { + const [visibleTodos, setVisibleTodos] = useState([]); + + useEffect(() => { + setVisibleTodos(getFilteredTodos(todos, filter)); + }, [todos, filter]); +} + +// GOOD: Filter during render (memoize if expensive) +function TodoList({ todos, filter }) { + const visibleTodos = useMemo( + () => getFilteredTodos(todos, filter), + [todos, filter] + ); +} +``` + +--- + +## 3. Resetting State on Prop Change + +```tsx +// BAD: Effect to reset state +function ProfilePage({ userId }) { + const [comment, setComment] = useState(''); + + useEffect(() => { + setComment(''); + }, [userId]); +} + +// GOOD: Use key prop +function ProfilePage({ userId }) { + return ; +} + +function Profile({ userId }) { + const [comment, setComment] = useState(''); // Resets automatically +} +``` + +**Why key works**: React treats components with different keys as different components, recreating state. + +--- + +## 4. Event-Specific Logic in Effect + +```tsx +// BAD: Effect for button click result +function ProductPage({ product, addToCart }) { + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name}!`); + } + }, [product]); + + function handleBuyClick() { + addToCart(product); + } +} + +// GOOD: Handle in event handler +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + } +} +``` + +**Why it's bad**: Effect fires on page refresh (isInCart is true), showing notification unexpectedly. + +--- + +## 5. Chains of Effects + +```tsx +// BAD: Effects triggering each other +function Game() { + const [card, setCard] = useState(null); + const [goldCardCount, setGoldCardCount] = useState(0); + const [round, setRound] = useState(1); + const [isGameOver, setIsGameOver] = useState(false); + + useEffect(() => { + if (card?.gold) setGoldCardCount(c => c + 1); + }, [card]); + + useEffect(() => { + if (goldCardCount > 3) { + setRound(r => r + 1); + setGoldCardCount(0); + } + }, [goldCardCount]); + + useEffect(() => { + if (round > 5) setIsGameOver(true); + }, [round]); +} + +// GOOD: Calculate in event handler +function Game() { + const [card, setCard] = useState(null); + const [goldCardCount, setGoldCardCount] = useState(0); + const [round, setRound] = useState(1); + const isGameOver = round > 5; // Derived! + + function handlePlaceCard(nextCard) { + if (isGameOver) throw Error('Game ended'); + + setCard(nextCard); + if (nextCard.gold) { + if (goldCardCount < 3) { + setGoldCardCount(goldCardCount + 1); + } else { + setGoldCardCount(0); + setRound(round + 1); + if (round === 5) alert('Good game!'); + } + } + } +} +``` + +**Why it's bad**: Multiple re-renders (setCard -> setGoldCardCount -> setRound -> setIsGameOver). Also fragile for features like history replay. + +--- + +## 6. Notifying Parent via Effect + +```tsx +// BAD: Effect to notify parent +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + + useEffect(() => { + onChange(isOn); + }, [isOn, onChange]); + + function handleClick() { + setIsOn(!isOn); + } +} + +// GOOD: Notify in same event +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + + function updateToggle(nextIsOn) { + setIsOn(nextIsOn); + onChange(nextIsOn); // Same event, batched render + } + + function handleClick() { + updateToggle(!isOn); + } +} + +// BEST: Fully controlled component +function Toggle({ isOn, onChange }) { + function handleClick() { + onChange(!isOn); + } +} +``` + +--- + +## 7. Passing Data Up to Parent + +```tsx +// BAD: Child fetches, passes up via Effect +function Parent() { + const [data, setData] = useState(null); + return ; +} + +function Child({ onFetched }) { + const data = useSomeAPI(); + + useEffect(() => { + if (data) onFetched(data); + }, [onFetched, data]); +} + +// GOOD: Parent fetches, passes down +function Parent() { + const data = useSomeAPI(); + return ; +} +``` + +**Why**: Data should flow down. Upward flow via Effects makes debugging hard. + +--- + +## 8. Fetching Without Cleanup (Race Condition) + +```tsx +// BAD: No cleanup - race condition +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + fetchResults(query).then(json => { + setResults(json); // "hello" response may arrive after "hell" + }); + }, [query]); +} + +// GOOD: Cleanup ignores stale responses +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + let ignore = false; + + fetchResults(query).then(json => { + if (!ignore) setResults(json); + }); + + return () => { ignore = true; }; + }, [query]); +} +``` + +--- + +## 9. App Initialization in Effect + +```tsx +// BAD: Runs twice in dev, may break auth +function App() { + useEffect(() => { + loadDataFromLocalStorage(); + checkAuthToken(); // May invalidate token on second call! + }, []); +} + +// GOOD: Module-level guard +let didInit = false; + +function App() { + useEffect(() => { + if (!didInit) { + didInit = true; + loadDataFromLocalStorage(); + checkAuthToken(); + } + }, []); +} + +// ALSO GOOD: Module-level execution +if (typeof window !== 'undefined') { + checkAuthToken(); + loadDataFromLocalStorage(); +} +``` diff --git a/src/BloomBrowserUI/AGENTS.md b/src/BloomBrowserUI/AGENTS.md index 0a5ec0663e44..b30c51f8e02e 100644 --- a/src/BloomBrowserUI/AGENTS.md +++ b/src/BloomBrowserUI/AGENTS.md @@ -35,39 +35,10 @@ When working in the front-end, cd to src/BloomBrowserUI ## About React useEffect -Rule 1 — Use useEffect when synchronizing with external systems: -Subscriptions, timers, or event listeners. +See {repository root}/.github/skills/react-useeffect -API calls or other asynchronous external operations. - -Updates to things outside React control (e.g., document.title, localStorage). - -Any side effect that cannot be computed during render. - -Rule 2 — Avoid useEffect when data can be derived or handled internally: - -State can be derived from props, context, or other state — compute in render. - -User interactions can be handled directly in event handlers. - -Local state reset/initialization can be handled by component keys or conditional rendering. - -Computed values can use useMemo or useCallback instead of syncing in an effect. - -Rule 3 — Validation heuristic: - -If removing the effect does not break external behavior, the effect is unnecessary. - -Implementation Tip for AI: - -Prefer pure render computation first. - -Add useEffect only when necessary for external side effects. - -Keep effects minimal and specific to their purpose; avoid overuse. - -Always include a comment before a useEffect explaining what it does and why it is necessary. +If you read that and decide that a useEffect is warranted, you must add a comment justifying why it is necessary. ## UI Tests diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 4e23213dbee6..9f453acecf7a 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -157,10 +157,6 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ () => propsForBloomDialog.open, ); - const [settings, setSettings] = React.useState( - undefined, - ); - const [pageSettings, setPageSettings] = React.useState< IPageSettings | undefined >(undefined); @@ -212,74 +208,66 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ } as unknown as ConfigrValues; }, [settings, pageSettings]); - const [appearanceDisabled, setAppearanceDisabled] = React.useState(false); - - // We use state here to allow the dialog UI to update without permanently changing the settings - // and getting notified of those changes. The changes are persisted when the user clicks OK. - const [theme, setTheme] = React.useState(""); - const [firstPossiblyLegacyCss, setFirstPossiblyLegacyCss] = - React.useState(""); - const [migratedTheme, setMigratedTheme] = React.useState(""); + const [deletedCustomBookStyles, setDeletedCustomBookStyles] = + React.useState(false); const initialPageAttributeSnapshot = React.useRef< ElementAttributeSnapshot | undefined >(undefined); - React.useEffect(() => { + const settings: IBookSettings | undefined = React.useMemo(() => { if (settingsString === "{}") { - return; // leave settings as undefined + return undefined; } if (typeof settingsString === "string") { - setSettings(JSON.parse(settingsString)); - } else { - setSettings(settingsString); + return JSON.parse(settingsString) as IBookSettings; } + return settingsString as unknown as IBookSettings; }, [settingsString]); + // Capture the current page settings and original page attributes once when the dialog mounts + // so Cancel can restore the page accurately; this is safe here because the dialog is only + // opened for an already-loaded editable page and getCurrentPageElement() should exist then. React.useEffect(() => { setPageSettings(getCurrentPageSettings()); initialPageAttributeSnapshot.current = ElementAttributeSnapshot.fromElement(getCurrentPageElement()); }, []); + // If the dialog unmounts while a nested color picker is open, clear the shared visibility flag + // so the parent dialog does not stay hidden after this component is gone. React.useEffect(() => { return () => { setDialogVisibleWhileColorPickerOpen(false); }; }, [setDialogVisibleWhileColorPickerOpen]); - React.useEffect(() => { - setFirstPossiblyLegacyCss( - appearanceUIOptions?.firstPossiblyLegacyCss ?? "", - ); - setMigratedTheme(appearanceUIOptions?.migratedTheme ?? ""); - }, [appearanceUIOptions]); - const bookSettingsTitle = useL10n( "Book and Page Settings", "BookAndPageSettings.Title", ); - React.useEffect(() => { - if (settings?.appearance) { - const liveAppearance = - (settingsToReturnLater?.["appearance"] as - | IAppearanceSettings - | undefined) ?? settings.appearance; - // when we're in legacy, we're just going to disable all the appearance controls - setAppearanceDisabled( - liveAppearance?.cssThemeName === "legacy-5-6", - ); - setTheme(liveAppearance?.cssThemeName ?? ""); - } - }, [settings, settingsToReturnLater]); + const firstPossiblyLegacyCss = deletedCustomBookStyles + ? "" + : (appearanceUIOptions?.firstPossiblyLegacyCss ?? ""); + const migratedTheme = deletedCustomBookStyles + ? "" + : (appearanceUIOptions?.migratedTheme ?? ""); + const liveAppearance = + (settingsToReturnLater?.["appearance"] as + | IAppearanceSettings + | undefined) ?? settings?.appearance; + const appearanceDisabled = liveAppearance?.cssThemeName === "legacy-5-6"; + + // We keep theme as a render-time value from the latest working settings so the dialog reflects + // Configr edits immediately without a second state synchronization layer. + const theme = liveAppearance?.cssThemeName ?? ""; const deleteCustomBookStyles = () => { post( `book/settings/deleteCustomBookStyles?file=${firstPossiblyLegacyCss}`, ); - setFirstPossiblyLegacyCss(""); - setMigratedTheme(""); + setDeletedCustomBookStyles(true); }; const tierAllowsFullPageCoverImage = diff --git a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx index d44c69b4f4c7..39b2d605f309 100644 --- a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx +++ b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx @@ -28,10 +28,6 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { propsForBloomDialog, } = useEventLaunchedBloomDialog("CollectionSettingsDialog"); - const [settings, setSettings] = React.useState( - undefined, - ); - const [settingsString, setSettingsString] = React.useState("{}"); // Fetch collection settings when the dialog opens so we sync with host state. React.useEffect(() => { @@ -41,21 +37,20 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { }); }, [propsForBloomDialog.open]); - const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< - ConfigrValues | undefined - >(undefined); - // Parse the settings JSON for Configr's initial values once it arrives. - React.useEffect(() => { + const settings = React.useMemo((): ConfigrValues | undefined => { if (settingsString === "{}") { - return; // leave settings as undefined + return undefined; } if (typeof settingsString === "string") { - setSettings(JSON.parse(settingsString) as ConfigrValues); - } else { - setSettings(settingsString as ConfigrValues); + return JSON.parse(settingsString) as ConfigrValues; } + return settingsString as unknown as ConfigrValues; }, [settingsString]); + const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< + ConfigrValues | undefined + >(undefined); + return ( = (props) => { addNewColorsToArrayIfNecessary, ]); - // Keep the focus callback current even though we attach DOM listeners only once. - const onInputFocusRef = useRef(props.onInputFocus); - useEffect(() => { - onInputFocusRef.current = props.onInputFocus; - }, [props.onInputFocus]); - - const focusFunc = (ev: FocusEvent) => { - onInputFocusRef.current(ev.currentTarget as HTMLElement); - }; + const onInputFocus = props.onInputFocus; - // Install focus listeners on inputs so the client can restore focus when canvas updates steal it. + // Install focus listeners on inputs so the client can restore focus when canvas updates steal it; + // this effect is necessary because the inputs live in the rendered DOM, not in React props/state, + // and we want the listener to stay aligned with the latest onInputFocus callback. useEffect(() => { const parent = dlgRef.current; if (!parent) { return; } + const focusFunc = (ev: FocusEvent) => { + onInputFocus(ev.currentTarget as HTMLElement); + }; + // When we make incremental color changes while editing one of these inputs, // the process of applying the changed color to the canvas element moves the focus // to the canvas element. This makes it painfully necessary to click back in the input @@ -278,7 +276,7 @@ const ColorPickerDialog: React.FC = (props) => { input.removeEventListener("focus", focusFunc), ); }; - }, []); + }, [onInputFocus]); const convertJsonColorArrayToColorInfos = ( jsonArray: IColorInfo[], From 4e636590cf8cd83ced4895ec39d1056007c44d2b Mon Sep 17 00:00:00 2001 From: Hatton Date: Fri, 27 Mar 2026 20:34:35 -0600 Subject: [PATCH 11/65] fix --- .../BookAndPageSettingsDialog.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 9f453acecf7a..17bea4e30bd8 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -196,6 +196,16 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ return settingsWithoutPage as IBookSettings; }; + const settings: IBookSettings | undefined = React.useMemo(() => { + if (settingsString === "{}") { + return undefined; + } + if (typeof settingsString === "string") { + return JSON.parse(settingsString) as IBookSettings; + } + return settingsString as unknown as IBookSettings; + }, [settingsString]); + const configrInitialValues: ConfigrValues | undefined = React.useMemo(() => { if (!settings || !pageSettings) { @@ -215,16 +225,6 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ ElementAttributeSnapshot | undefined >(undefined); - const settings: IBookSettings | undefined = React.useMemo(() => { - if (settingsString === "{}") { - return undefined; - } - if (typeof settingsString === "string") { - return JSON.parse(settingsString) as IBookSettings; - } - return settingsString as unknown as IBookSettings; - }, [settingsString]); - // Capture the current page settings and original page attributes once when the dialog mounts // so Cancel can restore the page accurately; this is safe here because the dialog is only // opened for an already-loaded editable page and getCurrentPageElement() should exist then. From 6f1f7738062004e5d9d51252e66d3ae5971b6b71 Mon Sep 17 00:00:00 2001 From: hatton Date: Sat, 28 Mar 2026 14:59:07 +0000 Subject: [PATCH 12/65] Add required useEffect justification comment per AGENTS.md --- .../react_components/color-picking/colorPickerDialog.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx index bbef431aa42a..51b32e7d50ac 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx @@ -592,6 +592,8 @@ export const ColorDisplayButton: React.FC = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.initialColor]); + // Clean up the visibility-changed callback when the component unmounts so the parent + // dialog is not left permanently hidden if the color picker was open at unmount time. useEffect(() => { return () => { if (onColorPickerVisibilityChanged) { From f9645df8d40dcceae02844c3285f860d0c7f1513 Mon Sep 17 00:00:00 2001 From: Hatton Date: Sat, 28 Mar 2026 09:28:39 -0600 Subject: [PATCH 13/65] Ensure page settings dialog save always clears open flag --- .../BookAndPageSettingsDialog.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 17bea4e30bd8..40e1479b6b59 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -291,20 +291,22 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ }, [closeDialogAndClearOpenFlag]); function saveSettingsAndCloseDialog() { - const latestSettings = - latestSettingsRef.current ?? settingsToReturnLater; - if (latestSettings) { - applyPageSettings( - parsePageSettingsFromConfigrValue(latestSettings), - ); - - const settingsToPost = - removePageSettingsFromConfigrSettings(latestSettings); - // If nothing changed, we don't get any...and don't need to make this call. - postJson("book/settings", settingsToPost); + try { + const latestSettings = + latestSettingsRef.current ?? settingsToReturnLater; + if (latestSettings) { + applyPageSettings( + parsePageSettingsFromConfigrValue(latestSettings), + ); + + const settingsToPost = + removePageSettingsFromConfigrSettings(latestSettings); + // If nothing changed, we don't get any...and don't need to make this call. + postJson("book/settings", settingsToPost); + } + } finally { + closeDialogAndClearOpenFlag(); } - - closeDialogAndClearOpenFlag(); // todo: how do we make the pageThumbnailList reload? It's in a different browser, so // we can't use a global. It listens to websocket, but we currently can only listen, // we cannot send. From b1fc17063755498ccbd8f5dccb20b3b3f85f5e07 Mon Sep 17 00:00:00 2001 From: hatton Date: Sat, 28 Mar 2026 15:08:46 +0000 Subject: [PATCH 14/65] Ensure isOpenAlready flag is always reset even if page element restoration throws --- .../BookAndPageSettingsDialog.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 40e1479b6b59..8a2a70c9e274 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -282,12 +282,15 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ }, [closeDialog]); const cancelAndCloseDialog = React.useCallback(() => { - if (initialPageAttributeSnapshot.current) { - initialPageAttributeSnapshot.current.restoreToElement( - getCurrentPageElement(), - ); + try { + if (initialPageAttributeSnapshot.current) { + initialPageAttributeSnapshot.current.restoreToElement( + getCurrentPageElement(), + ); + } + } finally { + closeDialogAndClearOpenFlag(); } - closeDialogAndClearOpenFlag(); }, [closeDialogAndClearOpenFlag]); function saveSettingsAndCloseDialog() { From b672c025ebbe004603e6103aa4823705d267467b Mon Sep 17 00:00:00 2001 From: Hatton Date: Sat, 28 Mar 2026 15:00:39 -0600 Subject: [PATCH 15/65] Clarify labels for "Collection Settings" and "Book and Page" settings --- DistFiles/localization/en/Bloom.xlf | 4 ++++ .../BookAndPageSettingsDialog.tsx | 13 ++++++++----- .../bookEdit/js/CanvasElementContextControls.tsx | 2 -- .../toolbox/canvas/customPageLayoutMenu.tsx | 1 + .../collection/CollectionSettingsDialog.tsx | 9 +++++++-- .../react_components/BookSettingsButton.tsx | 15 +++++++++++++-- .../CollectionTopBarControls.tsx | 15 +++++++++++++-- .../CollectionSettingsDialog.Designer.cs | 4 ++-- 8 files changed, 48 insertions(+), 15 deletions(-) diff --git a/DistFiles/localization/en/Bloom.xlf b/DistFiles/localization/en/Bloom.xlf index 0d3f18987f8e..f1217cd80509 100644 --- a/DistFiles/localization/en/Bloom.xlf +++ b/DistFiles/localization/en/Bloom.xlf @@ -550,6 +550,10 @@ Settings ID: CollectionSettingsDialog.CollectionSettingsWindowTitle + + Collection Settings + ID: CollectionSettingsDialog.Title + Change... ID: CollectionSettingsDialog.LanguageTab.ChangeLanguageLink diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 8a2a70c9e274..9e655b06da6a 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -65,8 +65,8 @@ export interface IBookSettings { // using the same keys as appearance.json. Currently the values are all // booleans. interface IOverrideInformation { - branding: object; - xmatter: object; + branding: Record; + xmatter: Record; brandingName: string; xmatterName: string; } @@ -130,9 +130,12 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ overrideDescription?: string; } { // some properties will be overridden by branding and/or xmatter - const xmatterOverride: T | undefined = - overrideInformation?.xmatter?.[subPath]; - const brandingOverride = overrideInformation?.branding?.[subPath]; + const xmatterOverride = overrideInformation?.xmatter?.[subPath] as + | T + | undefined; + const brandingOverride = overrideInformation?.branding?.[subPath] as + | T + | undefined; const override = xmatterOverride ?? brandingOverride; // nb: xmatterOverride can be boolean, hence the need to spell out !==undefined let description = diff --git a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx index 7b9c793f3d69..adf835308f0e 100644 --- a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx @@ -73,9 +73,7 @@ import { } from "../toolbox/canvas/canvasElementUtils"; import { wrapWithRequestPageContentDelay } from "./bloomEditing"; import { get, post, useApiObject } from "../../utils/bloomApi"; -import { ILanguageNameValues } from "../bookSettings/FieldVisibilityGroup"; import OverflowChecker from "../OverflowChecker/OverflowChecker"; -import { getString, post, useApiObject } from "../../utils/bloomApi"; import { ILanguageNameValues } from "../bookAndPageSettings/FieldVisibilityGroup"; interface IMenuItemWithSubmenu extends ILocalizableMenuItemProps { diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx index 1e32a186d131..118185ae80da 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx @@ -44,6 +44,7 @@ export const CustomPageLayoutMenu: React.FunctionComponent<{
diff --git a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx index 39b2d605f309..f8954c460377 100644 --- a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx +++ b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx @@ -18,6 +18,7 @@ import { DialogCancelButton, DialogOkButton, } from "../react_components/BloomDialog/commonDialogComponents"; +import { useL10n } from "../react_components/l10nHooks"; import { get, postJson } from "../utils/bloomApi"; import { kBloomBlue } from "../bloomMaterialUITheme"; @@ -50,6 +51,10 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< ConfigrValues | undefined >(undefined); + const dialogTitle = useL10n( + "Collection Settings", + "CollectionSettingsDialog.Title", + ); return ( { draggable={false} maxWidth={false} > - +
{ > {settings && ( { return ( ); }; diff --git a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx index 94dade484804..2fc8bd56421d 100644 --- a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx +++ b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx @@ -70,11 +70,22 @@ export const CollectionTopBarControls: React.FunctionComponent = () => { > Date: Sat, 28 Mar 2026 15:16:30 -0600 Subject: [PATCH 16/65] don't allow page setting on cover for now (it introduces complexities) --- .../BookAndPageSettingsDialog.tsx | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 9e655b06da6a..f0a47a82aaeb 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -163,6 +163,8 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ const [pageSettings, setPageSettings] = React.useState< IPageSettings | undefined >(undefined); + const [currentPageIsXMatter, setCurrentPageIsXMatter] = + React.useState(false); const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< ConfigrValues | undefined @@ -232,9 +234,14 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ // so Cancel can restore the page accurately; this is safe here because the dialog is only // opened for an already-loaded editable page and getCurrentPageElement() should exist then. React.useEffect(() => { + const currentPageElement = getCurrentPageElement(); setPageSettings(getCurrentPageSettings()); initialPageAttributeSnapshot.current = - ElementAttributeSnapshot.fromElement(getCurrentPageElement()); + ElementAttributeSnapshot.fromElement(currentPageElement); + setCurrentPageIsXMatter( + currentPageElement.classList.contains("bloom-frontMatter") || + currentPageElement.classList.contains("bloom-backMatter"), + ); }, []); // If the dialog unmounts while a nested color picker is open, clear the shared visibility flag @@ -339,6 +346,34 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ onColorPickerVisibilityChanged: setDialogVisibleWhileColorPickerOpen, }); + const initiallySelectedConfigrPageKey = currentPageIsXMatter + ? undefined + : props.initiallySelectedPageKey; + + const configrAreas = [ + + {bookSettingsArea.pages} + , + ]; + + if (!currentPageIsXMatter) { + configrAreas.push( + + {pageSettingsArea.pages} + , + ); + } + return ( - - {bookSettingsArea.pages} - - - {pageSettingsArea.pages} - + {configrAreas} )} From f9a7053c7713fe15d5c3c6128075f756939cadcc Mon Sep 17 00:00:00 2001 From: Hatton Date: Sat, 28 Mar 2026 16:50:03 -0600 Subject: [PATCH 17/65] normalize text display of all the controls that show above the page in all modes --- src/BloomBrowserUI/bookEdit/css/editMode.less | 6 +- .../bookEdit/css/origamiEditing.less | 31 ++- .../bookEdit/js/AbovePageControls.tsx | 207 ++++++++++++++++++ src/BloomBrowserUI/bookEdit/js/CogIcon.tsx | 13 +- .../bookEdit/js/bloomEditing.ts | 6 +- src/BloomBrowserUI/bookEdit/js/origami.ts | 155 ++++--------- .../bookEdit/js/pageSettingsButtonIcon.svg | 3 - .../toolbox/canvas/customPageLayoutMenu.tsx | 98 +++++---- .../toolbox/canvas/customXmatterPage.tsx | 115 ++++------ .../toolbox/games/DragActivityTabControl.tsx | 16 +- 10 files changed, 403 insertions(+), 247 deletions(-) create mode 100644 src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx delete mode 100644 src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg diff --git a/src/BloomBrowserUI/bookEdit/css/editMode.less b/src/BloomBrowserUI/bookEdit/css/editMode.less index e81d483073d2..9285a82a8598 100644 --- a/src/BloomBrowserUI/bookEdit/css/editMode.less +++ b/src/BloomBrowserUI/bookEdit/css/editMode.less @@ -560,7 +560,7 @@ body.bloom-fullBleed { // to the code that tries to keep the background image and canvas elements aligned. .imageButton, .split-pane-divider, - .origami-toggle { + .change-layout-mode-toggle { display: none !important; } @@ -1326,10 +1326,8 @@ body.hideAllCKEditors .cke_chrome { } // ----- Bloom Games ---- -// Tweak the position of the Start/Correct/Wrong/Play control +// Width is handled here; top-strip alignment is controlled alongside the other above-page controls. #drag-activity-tab-control { - position: relative; - top: -8px; width: 100%; } diff --git a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less index c255d42e0b7a..79c819e5c154 100644 --- a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less +++ b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less @@ -163,14 +163,32 @@ top: @ToggleVerticalOffset; width: 100%; display: flex; + align-items: center; justify-content: space-between; box-sizing: border-box; } -.origami-toggle { +#drag-activity-tab-control { + display: flex; + align-items: center; + height: 100%; + margin-left: auto; +} + +.above-page-control-typography { + font-family: @UIFontStack; + font-size: 9pt !important; + line-height: 16px !important; + font-weight: 400 !important; +} + +.change-layout-mode-toggle { + display: flex; + align-items: center; + gap: 6px; cursor: pointer; + margin-top: 2px; margin-right: 19px; - line-height: 1em; color: @DisabledColor; div { color: @ActiveSwitchColor; @@ -193,8 +211,10 @@ cursor: pointer; color: @bloom-purple; white-space: nowrap; - font-size: 12px; - line-height: 1; + font-family: inherit; + font-size: inherit; + line-height: inherit; + font-weight: inherit; &:hover { opacity: 0.8; @@ -204,11 +224,10 @@ width: 20px; height: 20px; flex-shrink: 0; + align-self: center; } .page-settings-button-label { - font-size: 11px; - line-height: 1; white-space: nowrap; } } diff --git a/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx new file mode 100644 index 000000000000..e202b1a72ed4 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx @@ -0,0 +1,207 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { post } from "../../utils/bloomApi"; +import { useL10n } from "../../react_components/l10nHooks"; +import { CustomPageLayoutMenu } from "../toolbox/canvas/customPageLayoutMenu"; +import { CogIcon } from "./CogIcon"; + +interface IAbovePageControlsState { + isGamePage: boolean; + showChangeLayoutModeToggle: boolean; + isChangeLayoutMode: boolean; + onChangeLayoutModeToggle?: () => void; + showPageLayoutMenu: boolean; + isCustomPageLayout: boolean; + disableCustomPage?: boolean; + onSetCustom?: ( + value: "standard" | "custom", + keepCustomLayoutDataWhenSwitchingToStandard: boolean, + ) => void; +} + +const defaultState: IAbovePageControlsState = { + isGamePage: false, + showChangeLayoutModeToggle: false, + isChangeLayoutMode: false, + showPageLayoutMenu: false, + isCustomPageLayout: false, +}; + +let currentState: IAbovePageControlsState = defaultState; + +export function updateAbovePageControls( + stateUpdate: Partial, +): void { + currentState = { + ...currentState, + ...stateUpdate, + }; + + renderAbovePageControls(); +} + +export function resetAbovePageControls(): void { + currentState = defaultState; + + const container = document.getElementsByClassName( + "above-page-control-container", + )[0] as HTMLElement | undefined; + if (container) { + ReactDOM.unmountComponentAtNode(container); + container.replaceChildren(); + } +} + +function renderAbovePageControls(): void { + const page = document.getElementsByClassName("bloom-page")[0] as + | HTMLElement + | undefined; + if (!page) { + return; + } + + const container = getOrCreateAbovePageControlContainer(page); + if (!container) { + return; + } + + ReactDOM.render( + , + container, + ); +} + +function getOrCreateAbovePageControlContainer( + page: HTMLElement, +): HTMLElement | undefined { + let container = document.getElementsByClassName( + "above-page-control-container", + )[0] as HTMLElement | undefined; + + if (!container) { + container = document.createElement("div"); + container.classList.add("above-page-control-container"); + container.classList.add("bloom-ui"); + + const pageScalingContainer = document.getElementById( + "page-scaling-container", + ); + if (pageScalingContainer) { + pageScalingContainer.prepend(container); + } else { + page.parentElement?.insertBefore( + container, + page.parentElement.firstChild, + ); + } + } + + container.style.maxWidth = page.clientWidth + "px"; + return container; +} + +const AbovePageControls: React.FunctionComponent = ( + props, +) => { + if (props.isGamePage) { + return ; + } + + return ( +
+
+ +
+
+ {props.showChangeLayoutModeToggle && ( + + )} + {props.showPageLayoutMenu && props.onSetCustom && ( + + )} +
+
+ ); +}; + +const PageSettingsButton: React.FunctionComponent = () => { + const label = useL10n("Page Settings", "PageSettings.Title"); + const title = useL10n("Open Page Settings...", "PageSettings.OpenTooltip"); + + return ( + + ); +}; + +const ChangeLayoutModeToggle: React.FunctionComponent<{ + isChecked: boolean; + onChange?: () => void; +}> = (props) => { + const label = useL10n("Change Layout", "EditTab.CustomPage.ChangeLayout"); + + return ( +
+
{label}
+
+ props.onChange?.()} + /> + +
+
+ ); +}; diff --git a/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx b/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx index 044060f94b4a..4ce82f4bc170 100644 --- a/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx @@ -1,8 +1,17 @@ import * as React from "react"; import { SvgIcon, SvgIconProps } from "@mui/material"; -export const CogIcon: React.FunctionComponent = (props) => ( - +export const CogIcon: React.FunctionComponent< + SvgIconProps & { size?: number | string } +> = (props) => ( + ); diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts index a67c6abd5370..3450659d7bb8 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts @@ -67,6 +67,7 @@ import PlaceholderProvider from "./PlaceholderProvider"; import { initChoiceWidgetsForEditing } from "./simpleComprehensionQuiz"; import { handleUndo } from "../workspaceRoot"; import { setupPageLayoutMenu } from "../toolbox/canvas/customXmatterPage"; +import { resetAbovePageControls } from "./AbovePageControls"; // Allows toolbox code to make an element properly in the context of this iframe. export function makeElement( @@ -1215,8 +1216,9 @@ export function localizeCkeditorTooltips(bar: JQuery) { // This is invoked when we are about to change pages. function removeEditingDebris() { - // We are mirroring the origami layoutToggleClickHandler() here, in case the user changes - // pages while the origami toggle in on. + resetAbovePageControls(); + // We are mirroring the changeLayoutModeToggleClickHandler() here, in case the user changes + // pages while the Change Layout mode toggle is on. // The DOM here is for just one page, so there's only ever one marginBox. const marginBox = document.getElementsByClassName("marginBox")[0]; marginBox.classList.remove("origami-layout-mode"); diff --git a/src/BloomBrowserUI/bookEdit/js/origami.ts b/src/BloomBrowserUI/bookEdit/js/origami.ts index 4d0b8d3ab506..0c8000e1d481 100644 --- a/src/BloomBrowserUI/bookEdit/js/origami.ts +++ b/src/BloomBrowserUI/bookEdit/js/origami.ts @@ -2,77 +2,53 @@ import { SetupImage } from "./bloomImages"; import { kBloomCanvasClass } from "../toolbox/canvas/canvasElementUtils"; import "../../lib/split-pane/split-pane.js"; import TextBoxProperties from "../TextBoxProperties/TextBoxProperties"; -import { post, postThatMightNavigate } from "../../utils/bloomApi"; +import { postThatMightNavigate } from "../../utils/bloomApi"; import { theOneCanvasElementManager } from "./CanvasElementManager"; import { getFeatureStatusAsync } from "../../react_components/featureStatus"; import $ from "jquery"; import "../../lib/jquery.i18n.custom"; -import pageSettingsButtonIconSvg from "./pageSettingsButtonIcon.svg?raw"; import { splitPane } from "../../lib/split-pane/split-pane"; import { kCanvasToolId } from "../toolbox/toolIds"; +import { updateAbovePageControls } from "./AbovePageControls"; $(() => { splitPane($("div.split-pane")); }); +let isWidgetFeatureEnabledForOrigami = false; +let isCanvasFeatureEnabledForOrigami = false; + export function setupOrigami() { getFeatureStatusAsync("widget").then((widgetFeatureStatus) => { getFeatureStatusAsync("canvas").then((canvasFeatureStatus) => { - const isWidgetFeatureEnabled: boolean = + isWidgetFeatureEnabledForOrigami = widgetFeatureStatus?.enabled || false; - const isCanvasFeatureEnabled: boolean = + isCanvasFeatureEnabledForOrigami = canvasFeatureStatus?.enabled || false; const customPages = document.getElementsByClassName("customPage"); const bloomPage = document.getElementsByClassName( "bloom-page", )[0] as HTMLElement | undefined; - const pageWidth = bloomPage?.clientWidth; - if (pageWidth !== undefined) { - const showOrigamiControls = customPages.length > 0; - const pageControlContainer = - getAbovePageControlContainer(showOrigamiControls); - - if (showOrigamiControls) { - pageControlContainer - .append( - createTypeSelectors( - isWidgetFeatureEnabled, - isCanvasFeatureEnabled, - ), - ) - .append(createTextBoxIdentifier()); - } - - // The order of this is not important in most ways, since it is positioned absolutely. - // However, we position the page label, also absolutely, in the same screen area, and - // we want it on top of origami control, so that in template pages the user can edit it. - // The page label is part of the page, so we want the page to come after the origami control. - // (Could also do this with z-order, but I prefer to do what I can by ordering elements, - // and save z-order for when it is really needed.) - $("#page-scaling-container").prepend(pageControlContainer); - // The container width is set to 100% in the CSS, but we need to - // limit it to no more than the actual width of the page. - const toggleContainer = $(".above-page-control-container").get( - 0, - ); - if (toggleContainer instanceof HTMLElement) { - toggleContainer.style.maxWidth = pageWidth + "px"; - } + if (bloomPage) { + const showChangeLayoutModeControls = customPages.length > 0; + updateAbovePageControls({ + isGamePage: + bloomPage?.getAttribute("data-tool-id") === "game", + showChangeLayoutModeToggle: showChangeLayoutModeControls, + isChangeLayoutMode: $(".marginBox").hasClass( + "origami-layout-mode", + ), + onChangeLayoutModeToggle: handleChangeLayoutModeToggle, + }); } // I'm not clear why the rest of this needs to wait until we have // the two results, but none of the controls shows up if we leave it all // outside the bloomApi functions. - $(".origami-toggle .onoffswitch").change(layoutToggleClickHandler); - $(".page-settings-button").click(pageSettingsButtonClickHandler); - if ($(".customPage .marginBox.origami-layout-mode").length) { setupLayoutMode(); - $("#myonoffswitch").prop("checked", true); } - $(".customPage, .above-page-control-container") - .find("*[data-i18n]") - .localize(); + $(".customPage").find("*[data-i18n]").localize(); }); }); } @@ -81,11 +57,6 @@ export function cleanupOrigami() { // Otherwise, we get a new one each time the page is loaded $(".split-pane-resize-shim").remove(); } -function isEmpty(el) { - const temp = $.trim(el[0].textContent); - //alert("-" + temp + "- equals empty string: " + (temp == "").toString()); - return temp === ""; -} function setupLayoutMode() { theOneCanvasElementManager.suspendComicEditing("forTool"); $(".split-pane-component-inner").each(function (): boolean { @@ -148,7 +119,7 @@ function doesSplitPaneComponentNeedTextBoxIdentifier(spci: JQuery) { return !spci.find(`${bloomContainerClasses} .selector-links`).length; } -function layoutToggleClickHandler() { +function changeLayoutModeToggleClickHandler() { const marginBox = $(".marginBox"); if (!marginBox.hasClass("origami-layout-mode")) { marginBox.addClass("origami-layout-mode"); @@ -169,7 +140,7 @@ function layoutToggleClickHandler() { marginBox.removeClass("origami-layout-mode"); marginBox.find(".textBox-identifier").remove(); origamiUndoStack.length = origamiUndoIndex = 0; - // delay further processing to avoid messing up origami toggle transition + // delay further processing to avoid messing up the Change Layout mode toggle transition // 400ms CSS toggle transition + 50ms extra to give it time to finish up. const toggleTransitionLength = 450; setTimeout(() => { @@ -179,6 +150,13 @@ function layoutToggleClickHandler() { } } +function handleChangeLayoutModeToggle() { + changeLayoutModeToggleClickHandler(); + updateAbovePageControls({ + isChangeLayoutMode: $(".marginBox").hasClass("origami-layout-mode"), + }); +} + function GetTextBoxPropertiesDialog() { return new TextBoxProperties("/bloom/bookEdit"); } @@ -230,7 +208,11 @@ function adjustModifiedChild(resizedElt: HTMLElement | undefined) { } } -const origamiUndoStack: any[] = []; +interface IOrigamiUndoItem { + original: JQuery; +} + +const origamiUndoStack: IOrigamiUndoItem[] = []; let origamiUndoIndex = 0; // of item that should be redone next, if any // Add a point to which the user can return using 'undo'. Call this before making any change that @@ -351,68 +333,6 @@ function getSplitPaneComponentInner() { return spci; } -function getAbovePageControlContainer(showOrigamiControls: boolean): JQuery { - // For dragActivities we reserve this wrapper for the game controls. - // Note: We also have to disable the Choose Different layout option in - // the right click menu, in PageListView.cs - if ( - document - .getElementsByClassName("bloom-page")[0] - ?.getAttribute("data-tool-id") === "game" - ) { - return $("
"); - } - - if (!showOrigamiControls) { - return $( - "
", - ).append(createPageSettingsButton()); - } - - return $("
") - .append(createPageSettingsButton()) - .append( - $( - `\ -
\ -
Change Layout
\ -
\ - \ - \ -
\ -
`, - ), - ); -} - -function createPageSettingsButton(): JQuery { - return $( - "", - ) - .append(createPageSettingsButtonIcon()) - .append( - $( - "Page Settings", - ), - ); -} - -function createPageSettingsButtonIcon(): SVGSVGElement { - const iconDocument = new DOMParser().parseFromString( - pageSettingsButtonIconSvg, - "image/svg+xml", - ); - return iconDocument.documentElement as unknown as SVGSVGElement; -} - -function pageSettingsButtonClickHandler(e: Event) { - e.preventDefault(); - post("editView/showPageSettingsDialog"); -} - function getButtons() { const buttons = $( "
", @@ -516,10 +436,15 @@ function createTextBoxIdentifier() { ).append(textBoxId); } function getTypeSelectors() { - return $(".container-selector-links > .selector-links").clone(true); + return createTypeSelectors( + isWidgetFeatureEnabledForOrigami, + isCanvasFeatureEnabledForOrigami, + ) + .find(".selector-links") + .first(); } function getTextBoxIdentifier() { - return $(".container-textBox-id > .textBox-identifier").clone(); + return createTextBoxIdentifier().find(".textBox-identifier").first(); } function makeTextFieldClickHandler(e) { e.preventDefault(); diff --git a/src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg b/src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg deleted file mode 100644 index a54556aba9c8..000000000000 --- a/src/BloomBrowserUI/bookEdit/js/pageSettingsButtonIcon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx index 118185ae80da..017fb408be94 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx @@ -1,7 +1,7 @@ import * as React from "react"; -import type { SelectChangeEvent } from "@mui/material/Select"; import { css, ThemeProvider } from "@emotion/react"; -import { Select } from "@mui/material"; +import { Button, Menu } from "@mui/material"; +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import { toolboxMenuPopupTheme } from "../../../bloomMaterialUITheme"; import { kBloomPurple } from "../../../utils/colorUtils"; import { useL10n } from "../../../react_components/l10nHooks"; @@ -15,6 +15,7 @@ export const CustomPageLayoutMenu: React.FunctionComponent<{ keepCustomLayoutDataWhenSwitchingToStandard: boolean, ) => void; }> = (props) => { + const [menuAnchor, setMenuAnchor] = React.useState(); const selectedLayoutLabel = useL10n( props.isCustom ? "Custom Layout" : "Standard Layout", props.isCustom @@ -22,20 +23,21 @@ export const CustomPageLayoutMenu: React.FunctionComponent<{ : "EditTab.CustomCover.StandardLayout", ); - const handleChange = (event: SelectChangeEvent) => { - const selection = event.target.value as "standard" | "custom"; - // TypeScript thinks the argument should be a SelectChangeEvent in order to pass - // the function as the onChange handler for a Select, but in fact it always - // comes in as a PointerEvent which has the keyboard modifier info we need. - const nativeEvent = (event as unknown as { nativeEvent?: PointerEvent }) - .nativeEvent; - const pointerEvent = nativeEvent ?? (event as unknown as PointerEvent); + const handleOpenMenu = (event: React.MouseEvent) => { + setMenuAnchor(event.currentTarget); + }; + + const handleCloseMenu = () => { + setMenuAnchor(undefined); + }; + + const handleSelect = ( + selection: "standard" | "custom", + event: React.MouseEvent, + ) => { const keepCustomLayoutDataWhenSwitchingToStandard = - selection === "standard" && - "shiftKey" in pointerEvent && - "ctrlKey" in pointerEvent && - pointerEvent.shiftKey && - pointerEvent.ctrlKey; + selection === "standard" && event.shiftKey && event.ctrlKey; + handleCloseMenu(); props.setCustom(selection, keepCustomLayoutDataWhenSwitchingToStandard); }; @@ -44,53 +46,65 @@ export const CustomPageLayoutMenu: React.FunctionComponent<{
- {selectedLayoutLabel} - +
); diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx index 81c0e0e41f42..7411e31193b0 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx @@ -1,8 +1,6 @@ // This collectionSettings reference defines the function GetSettings(): ICollectionSettings // The actual function is injected by C#. /// -import * as ReactDOM from "react-dom"; -import { CustomPageLayoutMenu } from "./customPageLayoutMenu"; import { CanvasElementManager, kBackgroundImageClass, @@ -17,6 +15,7 @@ import { recomputeSourceBubblesForPage, wrapWithRequestPageContentDelay, } from "../../js/bloomEditing"; +import { updateAbovePageControls } from "../../js/AbovePageControls"; import BloomSourceBubbles from "../../sourceBubbles/BloomSourceBubbles"; import { getToolboxBundleExports } from "../../js/workspaceFrames"; import { ILanguageNameValues } from "../../bookAndPageSettings/FieldVisibilityGroup"; @@ -398,27 +397,7 @@ export function setupPageLayoutMenu(): void { return; } - // Create the container if needed (which it usually will be, because the cover - // is not a customPage and doesn't get one automatically). This duplicates - // (but without jquery) some code in origami.ts - let container: HTMLElement | undefined = document.getElementsByClassName( - "above-page-control-container", - )[0] as HTMLElement; - if (!container) { - container = document.createElement("div") as HTMLElement; - container.classList.add("above-page-control-container"); - container.classList.add("bloom-ui"); - container.style.maxWidth = page.clientWidth + "px"; - // see commment in origami.ts about why we put it first. - // the code there puts it at the start of #page-scaling-container, but that - // is always the parent of .bloom-page, so this is equivalent. - page.parentElement?.insertBefore( - container, - page.parentElement.firstChild, - ); - } - - renderPageLayoutMenu(page as HTMLElement, container as HTMLElement); + renderPageLayoutMenu(page as HTMLElement); if (page.classList.contains("bloom-customLayout")) { ensureDerivedFieldsFitOnCustomPage(page as HTMLElement); @@ -436,54 +415,54 @@ function toggleCustomPageLayout( }); } -function renderPageLayoutMenu(page: HTMLElement, container: HTMLElement): void { - // Render a CustomPageLayoutMenu React component into this container +function renderPageLayoutMenu(page: HTMLElement): void { const isCustomPage = page.classList.contains("bloom-customLayout"); const usingLegacyTheme = isLegacyThemeCssLoaded(); - ReactDOM.render( - { + if (usingLegacyTheme && selection !== "standard") { + return; + } + const response = await toggleCustomPageLayout( + page.getAttribute("id")!, keepCustomLayoutDataWhenSwitchingToStandard, - ) => { - if (usingLegacyTheme && selection !== "standard") { - return; - } - const response = await toggleCustomPageLayout( + ); + if ( + selection === "custom" && + response && + // C# returns the string "false" if we don't have any saved state for custom mode, + // but currently something in axios converts that to a boolean false. + // I'm not sure that might not change one day, so we check for both. + (response.data === "false" || response.data === false) + ) { + // making a custom cover for the first time + await wrapWithRequestPageContentDelay( + () => convertXmatterPageToCustom(page), + "customPageLayout-convertFirstTime", + ); + // Set data-tool-id on the browser DOM so it persists when jumpToPage saves. + // (The C# toggleCustomPageLayout set it on the C# DOM, but returned early + // without SaveThen, so that change would be overwritten by the browser save.) + page.setAttribute("data-tool-id", "canvas"); + // Persist the newly created custom layout state so a later toggle back + // to standard has matching server-side state to work from. + await postString( + "editView/jumpToPage", page.getAttribute("id")!, - keepCustomLayoutDataWhenSwitchingToStandard, ); - if ( - selection === "custom" && - response && - // C# returns the string "false" if we don't have any saved state for custom mode, - // but currently something in axios converts that to a boolean false. - // I'm not sure that might not change one day, so we check for both. - (response.data === "false" || response.data === false) - ) { - // making a custom cover for the first time - await wrapWithRequestPageContentDelay( - () => convertXmatterPageToCustom(page), - "customPageLayout-convertFirstTime", - ); - // Set data-tool-id on the browser DOM so it persists when jumpToPage saves. - // (The C# toggleCustomPageLayout set it on the C# DOM, but returned early - // without SaveThen, so that change would be overwritten by the browser save.) - page.setAttribute("data-tool-id", "canvas"); - // Persist the newly created custom layout state so a later toggle back - // to standard has matching server-side state to work from. - await postString( - "editView/jumpToPage", - page.getAttribute("id")!, - ); - renderPageLayoutMenu(page, container); - } else if (selection === "custom" && response) { - showCanvasTool(); // otherwise called from convertXmatterPageToCustom()/finishReactivatingPage() - } - }} - />, - container, - ); + renderPageLayoutMenu(page); + } else if (selection === "custom" && response) { + showCanvasTool(); // otherwise called from convertXmatterPageToCustom()/finishReactivatingPage() + renderPageLayoutMenu(page); + } else if (response) { + renderPageLayoutMenu(page); + } + }, + }); } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx b/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx index edd4fc2e4ccd..fec5907be02e 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx @@ -49,16 +49,15 @@ export const DragActivityTabControl: React.FunctionComponent<{ return (
{promptButtonContent && ( @@ -90,6 +89,7 @@ export const DragActivityTabControl: React.FunctionComponent<{ css={css` margin-right: 20px; `} + className="above-page-control-typography" > {setupMode}
@@ -106,8 +106,12 @@ export const DragActivityTabControl: React.FunctionComponent<{ const buttonCss = css` color: white; width: auto; // override MUI's 100% + height: 24px; min-width: 32px; // override MUI's 64px + font-family: Roboto, NotoSans, sans-serif; + font-size: 9pt; font-weight: 400; + line-height: 16px; padding: 0px 6px; & .MuiButton-startIcon { top: -1px; @@ -154,6 +158,8 @@ export const Tabs: React.FunctionComponent<{ css={css` display: flex; background-color: ${kDarkestBackground}; + align-items: center; + height: 24px; `} className={props.className} > From 822af2bdda472704622c8452b56dd79bd27cc8ff Mon Sep 17 00:00:00 2001 From: Hatton Date: Sat, 28 Mar 2026 18:14:36 -0600 Subject: [PATCH 18/65] fix background color when in rounded theme --- .github/skills/bloom-automation/SKILL.md | 9 ++- .../PageSettingsConfigrPages.spec.ts | 71 +++++++++++++++++++ .../PageSettingsConfigrPages.tsx | 40 ++++++++++- 3 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts diff --git a/.github/skills/bloom-automation/SKILL.md b/.github/skills/bloom-automation/SKILL.md index 16919020fbf8..6cf6277d8ea4 100644 --- a/.github/skills/bloom-automation/SKILL.md +++ b/.github/skills/bloom-automation/SKILL.md @@ -1,6 +1,6 @@ --- name: bloom-automation -description: Use when an agent needs to determine if Bloom is already running, detect whether the running Bloom came from a different worktree, kill Bloom or dotnet-watch parents, start Bloom from the current worktree, attach to the embedded WebView2 over CDP, inspect DOM/console/network, or run Playwright tests against the actual exe instead of CURRENTPAGE. +description: Use when an agent needs to determine if Bloom is already running, detect whether the running Bloom came from a different worktree, kill Bloom or dotnet-watch parents, start Bloom from the current worktree, attach to the embedded WebView2 over CDP, inspect DOM/console/network, use dev-browser to inspect or run e2e tests against the actual exe instead of CURRENTPAGE. argument-hint: "repo root or worktree, task such as status, restart, attach, run exe-backed tests" user-invocable: true --- @@ -243,7 +243,6 @@ Report: - what browser-native evidence you collected: DOM state, console output, network request, tab state, or test results ## Example Prompts -- `Use bloom-automation to determine whether Bloom is already running from this worktree and attach Playwright to the embedded browser.` -- `Use bloom-automation to switch the already-running Bloom to the Edit tab.` -- `Use bloom-automation to kill the wrong-worktree Bloom and start the current checkout with dotnet watch.` -- `Use bloom-automation to run the exe-backed Playwright top bar smoke tests against the actual Bloom.exe window.` +- `troubleshoot why the page is refreshing when we open page settings` + +## Debugging tips: use node or bash scripts. Avoid powershell. Use the "dev-browser" cli instead of playwright for interactive debugging/driving Bloom. Use "dev-browser --help" to see the available commands and options. If the user hasn't installe dev-browser, as them for permission to install it (https://github.com/SawyerHood/dev-browser). diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts new file mode 100644 index 000000000000..41a0121824b0 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/shared", () => ({ + getPageIframeBody: () => document.body, +})); + +import { + applyPageSettings, + getCurrentPageSettings, +} from "./PageSettingsConfigrPages"; + +describe("PageSettingsConfigrPages", () => { + beforeEach(() => { + document.body.innerHTML = + '
'; + }); + + it("uses the visible margin box color when the theme separates it from the page", () => { + const page = document.body.querySelector(".bloom-page") as HTMLElement; + page.style.setProperty("--page-background-color", "#2e2e2e"); + page.style.setProperty("--marginBox-background-color", "#ffffff"); + + const settings = getCurrentPageSettings(); + + expect(settings.page.backgroundColor).toBe("#FFFFFF"); + }); + + it("preserves the themed outer page background when applying a page background color", () => { + const page = document.body.querySelector(".bloom-page") as HTMLElement; + page.style.setProperty("--page-background-color", "#2e2e2e"); + page.style.setProperty("--marginBox-background-color", "#ffffff"); + + applyPageSettings({ + page: { + backgroundColor: "#ABCDEF", + pageNumberColor: "#000000", + pageNumberOutlineColor: "transparent", + pageNumberBackgroundColor: "transparent", + }, + }); + + expect( + page.style.getPropertyValue("--marginBox-background-color"), + ).toBe("#ABCDEF"); + expect(page.style.getPropertyValue("--page-background-color")).toBe( + "#2e2e2e", + ); + }); + + it("updates both page and margin box colors when the theme uses one flat background", () => { + const page = document.body.querySelector(".bloom-page") as HTMLElement; + page.style.setProperty("--page-background-color", "#ffffff"); + page.style.setProperty("--marginBox-background-color", "#ffffff"); + + applyPageSettings({ + page: { + backgroundColor: "#ABCDEF", + pageNumberColor: "#000000", + pageNumberOutlineColor: "transparent", + pageNumberBackgroundColor: "transparent", + }, + }); + + expect( + page.style.getPropertyValue("--marginBox-background-color"), + ).toBe("#ABCDEF"); + expect(page.style.getPropertyValue("--page-background-color")).toBe( + "#ABCDEF", + ); + }); +}); diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx index 0e4304c28df9..c1404ce15145 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx @@ -127,6 +127,37 @@ const getCurrentPageBackgroundColor = (): string => { return computedBackground || "#FFFFFF"; }; +const getEffectivePageBackgroundColor = (page: HTMLElement): string => { + const computedPage = getComputedStyleForPage(page); + const computedVariable = normalizeToHexOrEmpty( + computedPage.getPropertyValue("--page-background-color"), + ); + if (computedVariable) return computedVariable; + + const computedBackground = normalizeToHexOrEmpty( + computedPage.backgroundColor, + ); + return computedBackground || "#FFFFFF"; +}; + +const getEffectiveMarginBoxBackgroundColor = (page: HTMLElement): string => { + const computedPage = getComputedStyleForPage(page); + const computedMarginBoxVariable = normalizeToHexOrEmpty( + computedPage.getPropertyValue("--marginBox-background-color"), + ); + if (computedMarginBoxVariable) return computedMarginBoxVariable; + + const marginBox = page.querySelector(".marginBox") as HTMLElement | null; + if (marginBox) { + const computedMarginBoxBackground = normalizeToHexOrEmpty( + getComputedStyleForPage(marginBox).backgroundColor, + ); + if (computedMarginBoxBackground) return computedMarginBoxBackground; + } + + return getEffectivePageBackgroundColor(page); +}; + const setOrRemoveCustomProperty = ( style: CSSStyleDeclaration, propertyName: string, @@ -155,12 +186,19 @@ const setOrRemoveCustomPropertyAllowTransparent = ( const setCurrentPageBackgroundColor = (color: string): void => { const page = getCurrentPageElement(); - setOrRemoveCustomProperty(page.style, "--page-background-color", color); + const effectivePageBackgroundColor = getEffectivePageBackgroundColor(page); + const effectiveMarginBoxBackgroundColor = + getEffectiveMarginBoxBackgroundColor(page); + setOrRemoveCustomProperty( page.style, "--marginBox-background-color", color, ); + + if (effectivePageBackgroundColor === effectiveMarginBoxBackgroundColor) { + setOrRemoveCustomProperty(page.style, "--page-background-color", color); + } }; const getPageNumberColor = (): string => { From 3d5e70020672a543005f9ce40fe9e50f362e0c07 Mon Sep 17 00:00:00 2001 From: Hatton Date: Sun, 29 Mar 2026 13:59:38 -0600 Subject: [PATCH 19/65] Fix localization of origami choices --- src/BloomBrowserUI/bookEdit/js/origami.ts | 51 +++++++++++++++++++---- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/js/origami.ts b/src/BloomBrowserUI/bookEdit/js/origami.ts index 6e077e252e9a..bece3863fe82 100644 --- a/src/BloomBrowserUI/bookEdit/js/origami.ts +++ b/src/BloomBrowserUI/bookEdit/js/origami.ts @@ -25,6 +25,7 @@ export function setupOrigami() { widgetFeatureStatus?.enabled || false; isCanvasFeatureEnabledForOrigami = canvasFeatureStatus?.enabled || false; + replaceOrigamiTemplates(); const customPages = document.getElementsByClassName("customPage"); const bloomPage = document.getElementsByClassName( "bloom-page", @@ -57,6 +58,43 @@ export function cleanupOrigami() { // Otherwise, we get a new one each time the page is loaded $(".split-pane-resize-shim").remove(); } + +function replaceOrigamiTemplates() { + $(".origami-template-container").remove(); + + const templateContainer = $( + "", + ); + templateContainer + .append( + createTypeSelectors( + isWidgetFeatureEnabledForOrigami, + isCanvasFeatureEnabledForOrigami, + ), + ) + .append(createTextBoxIdentifier()); + + const pageScalingContainer = document.getElementById( + "page-scaling-container", + ); + if (pageScalingContainer) { + $(pageScalingContainer).prepend(templateContainer); + } else { + $(".customPage").first().before(templateContainer); + } + + templateContainer.find("*[data-i18n]").localize(); +} + +function getRequiredOrigamiTemplate(selector: string) { + const template = $(".origami-template-container").find(selector).first(); + if (!template.length) { + throw new Error(`Missing origami template for ${selector}`); + } + + return template; +} + function setupLayoutMode() { theOneCanvasElementManager.suspendComicEditing("forTool"); $(".split-pane-component-inner").each(function (): boolean { @@ -435,15 +473,14 @@ function createTextBoxIdentifier() { ).append(textBoxId); } function getTypeSelectors() { - return createTypeSelectors( - isWidgetFeatureEnabledForOrigami, - isCanvasFeatureEnabledForOrigami, - ) - .find(".selector-links") - .first(); + return getRequiredOrigamiTemplate( + ".container-selector-links > .selector-links", + ).clone(true); } function getTextBoxIdentifier() { - return createTextBoxIdentifier().find(".textBox-identifier").first(); + return getRequiredOrigamiTemplate( + ".container-textBox-id > .textBox-identifier", + ).clone(); } function makeTextFieldClickHandler(e) { e.preventDefault(); From 456a8685d2297d75bfee31eeda4e3e233c53bdc7 Mon Sep 17 00:00:00 2001 From: Hatton Date: Sun, 29 Mar 2026 15:48:20 -0600 Subject: [PATCH 20/65] Remove backend involvement in opening book and page settings --- .github/skills/bloom-automation/SKILL.md | 3 +- .../bookEdit/js/AbovePageControls.tsx | 6 ++-- src/BloomBrowserUI/bookEdit/workspaceRoot.ts | 14 +--------- .../react_components/BookSettingsButton.tsx | 9 ++++-- src/BloomExe/Edit/EditingView.cs | 28 ------------------- .../web/controllers/EditingViewApi.cs | 22 --------------- 6 files changed, 14 insertions(+), 68 deletions(-) diff --git a/.github/skills/bloom-automation/SKILL.md b/.github/skills/bloom-automation/SKILL.md index 6cf6277d8ea4..0e9e60253881 100644 --- a/.github/skills/bloom-automation/SKILL.md +++ b/.github/skills/bloom-automation/SKILL.md @@ -219,8 +219,9 @@ These tests attach to the real Bloom.exe target over CDP and verify tab switchin - Exact-target cleanup is intentionally strict: `killBloomProcess.mjs --http-port ` should only kill the instance that actually reports that HTTP port, and should fail without killing anything if that target cannot be resolved. - When reporting work, include the helper commands you used so reviewers can confirm the workflow stayed on the supported path. - Wrong-worktree detection is authoritative when a real `Bloom.exe` child exists or when `dotnet watch` was started with an absolute `--project` path. -- A standalone `dotnet watch` started with a relative project path may not expose enough information to attribute it to a worktree. For current-worktree automation, start Bloom through `node scripts/watchBloomExe.mjs`, which always uses an absolute path. For the already-running Bloom workflow, use `--running-bloom` instead of trying to infer a worktree. - When more than one Bloom is running from the same worktree, repo-root matching is not enough. Use the explicit HTTP port workflow. +- For ad hoc local debugging in this workspace, `dev-browser --connect http://localhost:` can attach directly to the existing Bloom WebView2 target. Use it as a low-friction inspection client. +- After attaching to Bloom's WebView2 target, if Bloom is on the Edit tab, the editable page content lives inside the iframe named `page`; the top-level document mostly hosts shell UI plus the root dialog container. ## Completion Checks - Bloom's status is known: not running, running from current worktree, or running from different worktree. diff --git a/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx index e202b1a72ed4..3ed6ee44fc9c 100644 --- a/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx @@ -1,9 +1,9 @@ import { css } from "@emotion/react"; import * as React from "react"; import * as ReactDOM from "react-dom"; -import { post } from "../../utils/bloomApi"; import { useL10n } from "../../react_components/l10nHooks"; import { CustomPageLayoutMenu } from "../toolbox/canvas/customPageLayoutMenu"; +import { getWorkspaceBundleExports } from "./workspaceFrames"; import { CogIcon } from "./CogIcon"; interface IAbovePageControlsState { @@ -167,7 +167,9 @@ const PageSettingsButton: React.FunctionComponent = () => {
); } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx b/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx index fec5907be02e..f359d3f1d301 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx @@ -6,7 +6,6 @@ import { kDarkestBackground, toolboxTheme, } from "../../../bloomMaterialUITheme"; -import * as ReactDOM from "react-dom"; import { getToolboxBundleExports } from "../../js/workspaceFrames"; import { useL10n } from "../../../react_components/l10nHooks"; import { default as PencilIcon } from "@mui/icons-material/Edit"; @@ -127,23 +126,6 @@ const buttonCss = css` // also occurs in less files export const kIdForDragActivityTabControl = "drag-activity-tab-control"; -// This is the function that the editable page iframe exports so that the toolbox can call it -// to render the Start/Correct/Wrong/Play control. -// This deliberately does NOT use the cross-iframe getPage() function, because it MUST be -// called in a way that has it executing in the right context, where document refers to the -// page iframe document. The toolbox must call it through getEditablePageBundleExports(). -// This is because ReactDOM.render seems to have trouble if we pass it an element that -// belongs to a different document. -export function renderDragActivityTabControl(currentTab: number) { - const root = document.getElementById(kIdForDragActivityTabControl); - if (!root) { - // not created yet, try later - setTimeout(() => renderDragActivityTabControl(currentTab), 200); - return; - } - ReactDOM.render(, root); -} - export const Tabs: React.FunctionComponent<{ value: number; onChange: (newValue: number) => void; From ed8e2d903545fe606cc5111da40d51a2e3034bc3 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 10:30:46 -0600 Subject: [PATCH 28/65] Handle slow toolbox accordion initialization. --- .../bookEdit/toolbox/toolbox.ts | 106 +++++++++++++++--- 1 file changed, 90 insertions(+), 16 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts b/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts index d2a63a6dcdb5..71b16bd71475 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts @@ -585,6 +585,7 @@ export class ToolBox { const activeToolId = getActiveToolIdFromCurrentToolboxUi(); const checkBoxId = toolId + "Check"; beginAddTool(checkBoxId, toolIdWithTool, false, () => { + invalidatePendingJQueryAccordionActivationRetries(); const adapter = getToolboxReactAdapter(); if (adapter) { if (activeToolId) { @@ -676,6 +677,8 @@ export function getMasterToolList() { const masterToolList: ITool[] = []; let currentTool: ITool | undefined = undefined; let toolboxReactActivationHooked = false; +let jQueryAccordionActivationHooked = false; +let latestJQueryAccordionActivationRequestId = 0; // The AI decided to create this react adapter object and save in in a window variable. // It gets set in a useEffect in the React component that is the root of the toolbox. @@ -717,6 +720,29 @@ function isAccordionInitialized(toolboxElement: JQuery): boolean { return toolboxElement.hasClass("ui-accordion"); } +function ensureJQueryAccordionActivationHook(toolbox: JQuery): void { + if (jQueryAccordionActivationHooked) { + return; + } + + toolbox.onSafe("accordionactivate.toolbox", (event, ui) => { + let newToolName = ""; + if (ui.newHeader.attr("data-toolId")) { + newToolName = ui.newHeader.attr("data-toolId").toString(); + } + switchTool(newToolName); + }); + jQueryAccordionActivationHooked = true; +} + +function invalidatePendingJQueryAccordionActivationRetries(): void { + latestJQueryAccordionActivationRequestId++; +} + +const initialAccordionInitializationRetryDelayInMilliseconds = 10; +const maximumAccordionInitializationRetryDelayInMilliseconds = 50; +const maxMillisecondsToWaitForAccordionInitialization = 3000; + // This primarily calls the detachFromPage method of the current tool, if any. // It also tries to find the current toolbox instance (in the right iframe, wherever it is called), // and runs any cleanup tasks that have been registered for when closing the tool. @@ -999,6 +1025,8 @@ export function removeToolboxMarkup() { } function switchTool(newToolName: string): void { + invalidatePendingJQueryAccordionActivationRetries(); + // Have Bloom remember which tool is active. (Might be none) postString("editView/saveToolboxSetting", "current\t" + newToolName); let newTool: ITool | null = null; @@ -1088,7 +1116,12 @@ async function activateToolInternalAsync( * This function attempts to activate the tool whose "data-toolId" attribute is equal to the value * of "currentTool" (the last tool displayed). */ -function setCurrentTool(toolID: string, retryCount = 0) { +function setCurrentTool( + toolID: string, + retryCount = 0, + waitStartTime = Date.now(), + requestId = ++latestJQueryAccordionActivationRequestId, +) { // I'm downright grumpy about how this code sometimes uses names with "Tool" appended, sometimes doesn't. // For now I'm just making functions work with either form. toolID = ToolBox.addToolToString(toolID); @@ -1119,11 +1152,61 @@ function setCurrentTool(toolID: string, retryCount = 0) { // NOTE: tools without a "data-toolId" attribute (such as the More tool) cannot be the "currentTool." let idx = 0; const toolbox = $("#toolbox"); + ensureJQueryAccordionActivationHook(toolbox); + + if (requestId !== latestJQueryAccordionActivationRequestId) { + return; + } + if (!isAccordionInitialized(toolbox)) { - if (retryCount >= 50) { - throw new Error("Toolbox accordion did not initialize."); + const elapsedTime = Date.now() - waitStartTime; + if (elapsedTime >= maxMillisecondsToWaitForAccordionInitialization) { + const accordionHeaders = toolbox.find("> h3"); + const requestedToolIsAvailable = !!( + toolID && + accordionHeaders.filter(function () { + return $(this).attr("data-toolId") === toolID; + }).length + ); + const currentToolId = currentTool + ? ToolBox.addToolToString(currentTool.id()) + : ""; + const currentToolIsAvailable = !!( + currentToolId && + accordionHeaders.filter(function () { + return $(this).attr("data-toolId") === currentToolId; + }).length + ); + const fallbackToolId = requestedToolIsAvailable + ? toolID + : currentToolIsAvailable + ? currentToolId + : ((accordionHeaders.first().attr("data-toolId") as + | string + | undefined) ?? ""); + console.error( + `Toolbox accordion did not initialize within ${elapsedTime}ms while activating ${toolID || "the default tool"}. Falling back without waiting for the accordion UI.`, + ); + if (fallbackToolId) { + switchTool(fallbackToolId); + } + return; } - window.setTimeout(() => setCurrentTool(toolID, retryCount + 1), 0); + const retryDelay = Math.min( + initialAccordionInitializationRetryDelayInMilliseconds * + (retryCount + 1), + maximumAccordionInitializationRetryDelayInMilliseconds, + ); + window.setTimeout( + () => + setCurrentTool( + toolID, + retryCount + 1, + waitStartTime, + requestId, + ), + retryDelay, + ); return; } @@ -1164,18 +1247,6 @@ function setCurrentTool(toolID: string, retryCount = 0) { // turn animation back on toolbox.accordion("option", "animate", ani); - // when a tool is activated, save its data-toolId so state can be restored when Bloom is restarted. - // We do this after we actually set the initial tool, because setting the intial tool may not CHANGE - // the active tool (if it's already the one we want, typically the first), so we can't rely on - // the activate event happening in the initial call. Instead, we make SURE to call it for the - // tool we are making active. - toolbox.onSafe("accordionactivate.toolbox", (event, ui) => { - let newToolName = ""; - if (ui.newHeader.attr("data-toolId")) { - newToolName = ui.newHeader.attr("data-toolId").toString(); - } - switchTool(newToolName); - }); //alert("switching to " + currentTool + " which has index " + toolIndex); //setTimeout(e => switchTool(currentTool), 700); switchTool(toolID); @@ -1808,10 +1879,12 @@ function loadToolboxTool( if (adapter) { const toolId = header.attr("data-toolId"); if (toolId) { + invalidatePendingJQueryAccordionActivationRetries(); adapter.setActiveToolByToolId(toolId); switchTool(toolId); } } else { + invalidatePendingJQueryAccordionActivationRetries(); toolboxElt.accordion("refresh"); const id = header.attr("id"); const toolNumber = parseInt( @@ -1843,6 +1916,7 @@ function showToolboxChanged(wasShowing: boolean): void { `Toolbox deactivating: ${currentTool.id()}`, ); } else { + invalidatePendingJQueryAccordionActivationRetries(); activateTool(currentTool); } } else { From 4c47d76ffc16921fa7c471dce336e6b961dbebb9 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 11:06:50 -0600 Subject: [PATCH 29/65] Simplify toolbox accordion retry handling. --- .../bookEdit/toolbox/toolbox.ts | 54 ++++--------------- 1 file changed, 9 insertions(+), 45 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts b/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts index 71b16bd71475..833f0f378377 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts @@ -678,7 +678,6 @@ const masterToolList: ITool[] = []; let currentTool: ITool | undefined = undefined; let toolboxReactActivationHooked = false; let jQueryAccordionActivationHooked = false; -let latestJQueryAccordionActivationRequestId = 0; // The AI decided to create this react adapter object and save in in a window variable. // It gets set in a useEffect in the React component that is the root of the toolbox. @@ -725,6 +724,8 @@ function ensureJQueryAccordionActivationHook(toolbox: JQuery): void { return; } + // Even if startup gives up waiting for the accordion, later user clicks still need + // to flow through switchTool() once the accordion finishes initializing. toolbox.onSafe("accordionactivate.toolbox", (event, ui) => { let newToolName = ""; if (ui.newHeader.attr("data-toolId")) { @@ -735,10 +736,6 @@ function ensureJQueryAccordionActivationHook(toolbox: JQuery): void { jQueryAccordionActivationHooked = true; } -function invalidatePendingJQueryAccordionActivationRetries(): void { - latestJQueryAccordionActivationRequestId++; -} - const initialAccordionInitializationRetryDelayInMilliseconds = 10; const maximumAccordionInitializationRetryDelayInMilliseconds = 50; const maxMillisecondsToWaitForAccordionInitialization = 3000; @@ -1025,8 +1022,6 @@ export function removeToolboxMarkup() { } function switchTool(newToolName: string): void { - invalidatePendingJQueryAccordionActivationRetries(); - // Have Bloom remember which tool is active. (Might be none) postString("editView/saveToolboxSetting", "current\t" + newToolName); let newTool: ITool | null = null; @@ -1120,7 +1115,6 @@ function setCurrentTool( toolID: string, retryCount = 0, waitStartTime = Date.now(), - requestId = ++latestJQueryAccordionActivationRequestId, ) { // I'm downright grumpy about how this code sometimes uses names with "Tool" appended, sometimes doesn't. // For now I'm just making functions work with either form. @@ -1154,36 +1148,13 @@ function setCurrentTool( const toolbox = $("#toolbox"); ensureJQueryAccordionActivationHook(toolbox); - if (requestId !== latestJQueryAccordionActivationRequestId) { - return; - } - if (!isAccordionInitialized(toolbox)) { const elapsedTime = Date.now() - waitStartTime; if (elapsedTime >= maxMillisecondsToWaitForAccordionInitialization) { - const accordionHeaders = toolbox.find("> h3"); - const requestedToolIsAvailable = !!( - toolID && - accordionHeaders.filter(function () { - return $(this).attr("data-toolId") === toolID; - }).length - ); - const currentToolId = currentTool - ? ToolBox.addToolToString(currentTool.id()) - : ""; - const currentToolIsAvailable = !!( - currentToolId && - accordionHeaders.filter(function () { - return $(this).attr("data-toolId") === currentToolId; - }).length - ); - const fallbackToolId = requestedToolIsAvailable - ? toolID - : currentToolIsAvailable - ? currentToolId - : ((accordionHeaders.first().attr("data-toolId") as - | string - | undefined) ?? ""); + const fallbackToolId = + (toolbox.find("> h3").first().attr("data-toolId") as + | string + | undefined) ?? toolID; console.error( `Toolbox accordion did not initialize within ${elapsedTime}ms while activating ${toolID || "the default tool"}. Falling back without waiting for the accordion UI.`, ); @@ -1197,14 +1168,10 @@ function setCurrentTool( (retryCount + 1), maximumAccordionInitializationRetryDelayInMilliseconds, ); + // A 0ms retry loop can burn through the whole retry budget before jQuery finishes + // initializing on a slow startup, so back off a little and cap the total wait time. window.setTimeout( - () => - setCurrentTool( - toolID, - retryCount + 1, - waitStartTime, - requestId, - ), + () => setCurrentTool(toolID, retryCount + 1, waitStartTime), retryDelay, ); return; @@ -1879,12 +1846,10 @@ function loadToolboxTool( if (adapter) { const toolId = header.attr("data-toolId"); if (toolId) { - invalidatePendingJQueryAccordionActivationRetries(); adapter.setActiveToolByToolId(toolId); switchTool(toolId); } } else { - invalidatePendingJQueryAccordionActivationRetries(); toolboxElt.accordion("refresh"); const id = header.attr("id"); const toolNumber = parseInt( @@ -1916,7 +1881,6 @@ function showToolboxChanged(wasShowing: boolean): void { `Toolbox deactivating: ${currentTool.id()}`, ); } else { - invalidatePendingJQueryAccordionActivationRetries(); activateTool(currentTool); } } else { From b6d15607545b207f722d03e457fcd368f2246a8f Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 12:21:41 -0600 Subject: [PATCH 30/65] obsolete l10n key --- .github/prompts/bloom-l10.prompt.md | 4 ++++ DistFiles/localization/en/BloomMediumPriority.xlf | 1 + 2 files changed, 5 insertions(+) diff --git a/.github/prompts/bloom-l10.prompt.md b/.github/prompts/bloom-l10.prompt.md index eba2cf0c3f05..5d8b1668bf86 100644 --- a/.github/prompts/bloom-l10.prompt.md +++ b/.github/prompts/bloom-l10.prompt.md @@ -36,9 +36,13 @@ the string id may be used by translators as they try to understand context or tr ## Expose ID to translators Add a note like this: `ID: LinkTargetChooser.URL.Paste.Tooltip` +## Legacy strings +Our localization build system is such that if we are no longer using a string ID in the next version, we cannot remove the ID from the XLF file immediately. This is because if we release a new version of the previous release, We will still need that old localization ID. The fact that its code base still has it will not be sufficient. Somehow the actual crowd in database will Lose the translations when it sees the string ID removed from a newer version. Therefore when we stop using a string ID we just add a not like this: "Obsolete as of 6.2". You can figure out the current version from the `Version` property on build/Bloom.proj. + ## Add comments for translators Although we don't want to fill in l10nComment in useL10n, we do want to fill in the note field to give context to translators. They don't know where the string appears in the UI, they also might need some explanation of what it means. For example, for the above string, we might add a note like `This is the text on a button in the Foobar dialog that brightens all images in the current book.` # Tips * Never use the word "Aria" in ids or comments. Translators don't know what that means. * Stop processing immediately if I haven't told you what priority we want. After you have the priority, then you can continue. + diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index 969c0107c029..b9703db785f4 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -731,6 +731,7 @@ Book Settings BookSettings.Title the heading of the dialog + Obsolete as of 6.4 Book and Page Settings From dbf7f9d4d05b1eb7d7d67345da77a7c9ecc4fd97 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 12:39:38 -0600 Subject: [PATCH 31/65] Handle early Book Settings click safely Review comment from copilot-pull-request-reviewer: getCurrentPageElement() throws if the page iframe/body is not available yet, so clicking Book and Page Settings early in Edit tab load could crash instead of opening the dialog. Fix: added a small helper that falls back to contentPages when the current page is unavailable, and added a focused Vitest regression covering both the fallback and cover-page cases. --- .../BookSettingsButton.spec.tsx | 28 +++++++++++++++++++ .../react_components/BookSettingsButton.tsx | 14 ++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 src/BloomBrowserUI/react_components/BookSettingsButton.spec.tsx diff --git a/src/BloomBrowserUI/react_components/BookSettingsButton.spec.tsx b/src/BloomBrowserUI/react_components/BookSettingsButton.spec.tsx new file mode 100644 index 000000000000..9df4b78b79b6 --- /dev/null +++ b/src/BloomBrowserUI/react_components/BookSettingsButton.spec.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../bookEdit/bookAndPageSettings/PageSettingsConfigrPages", () => ({ + getCurrentPageElement: vi.fn(), +})); + +import { getCurrentPageElement } from "../bookEdit/bookAndPageSettings/PageSettingsConfigrPages"; +import { getInitialBookSettingsPageKey } from "./BookSettingsButton"; + +const getCurrentPageElementMock = vi.mocked(getCurrentPageElement); + +describe("BookSettingsButton", () => { + it("defaults to content pages if the current page is not available yet", () => { + getCurrentPageElementMock.mockImplementation(() => { + throw new Error("page iframe not ready"); + }); + + expect(getInitialBookSettingsPageKey()).toBe("contentPages"); + }); + + it("uses the cover page key when the current page is a cover", () => { + const page = document.createElement("div"); + page.classList.add("cover"); + getCurrentPageElementMock.mockReturnValue(page); + + expect(getInitialBookSettingsPageKey()).toBe("cover"); + }); +}); diff --git a/src/BloomBrowserUI/react_components/BookSettingsButton.tsx b/src/BloomBrowserUI/react_components/BookSettingsButton.tsx index 2222d9da3094..1fc36550feee 100644 --- a/src/BloomBrowserUI/react_components/BookSettingsButton.tsx +++ b/src/BloomBrowserUI/react_components/BookSettingsButton.tsx @@ -12,11 +12,19 @@ import { const bookSettingsIconPath = `${getBloomApiPrefix(false)}images/book-settings.png`; -export const BookSettingsButton: React.FunctionComponent = (props) => { - const handleClick = React.useCallback(() => { - const pageKey = getCurrentPageElement().classList.contains("cover") +export const getInitialBookSettingsPageKey = (): string => { + try { + return getCurrentPageElement().classList.contains("cover") ? "cover" : "contentPages"; + } catch { + return "contentPages"; + } +}; + +export const BookSettingsButton: React.FunctionComponent = (props) => { + const handleClick = React.useCallback(() => { + const pageKey = getInitialBookSettingsPageKey(); showBookSettingsDialog(pageKey); }, []); From 93d0ac14fb62e4a1bb6261e71a8359e0a2e3a481 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 12:40:15 -0600 Subject: [PATCH 32/65] Make color picker hide safe before show Review comment from copilot-pull-request-reviewer: hideColorPickerDialog() unconditionally called externalSetOpen(false) even though externalSetOpen was only assigned after the dialog rendered, so hide-before-show or a render failure could throw at runtime. Fix: initialize externalSetOpen to a no-op so hide is safe before any render, and add a focused Vitest regression that hide-before-show does not throw. --- .../color-picking/colorPickerDialog.spec.ts | 9 +++++++++ .../react_components/color-picking/colorPickerDialog.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.spec.ts diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.spec.ts b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.spec.ts new file mode 100644 index 000000000000..e2e4c9218dd6 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import { hideColorPickerDialog } from "./colorPickerDialog"; + +describe("colorPickerDialog", () => { + it("does not throw if hide is called before show", () => { + expect(() => hideColorPickerDialog()).not.toThrow(); + }); +}); diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx index 99c6f9598988..a38fc4997bb6 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx @@ -85,7 +85,7 @@ export interface IColorPickerDialogProps { //defaultColor?: IColorInfo; eventually we'll need this } -let externalSetOpen: React.Dispatch>; +let externalSetOpen: React.Dispatch> = () => {}; const ColorPickerDialog: React.FC = (props) => { const MAX_SWATCHES = 21; From cde4844c4049eed1478dc959251b0a24f1d00d34 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 14:11:13 -0600 Subject: [PATCH 33/65] hide page settings button, relabel "page"-->"Current Page" --- DistFiles/localization/en/BloomMediumPriority.xlf | 2 +- .../BookAndPageSettingsDialog.test.tsx | 2 +- .../BookAndPageSettingsDialog.tsx | 14 ++++++++++++-- .../PageSettingsConfigrPages.tsx | 5 ++++- .../bookEdit/js/AbovePageControls.tsx | 6 +++--- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index b9703db785f4..f9164e8dc397 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -749,7 +749,7 @@ Description text shown for the Book area in the combined Book and Page Settings dialog. - Page + Current Page ID: BookAndPageSettings.PageArea Area label for tabs/pages that affect only the current page. diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.test.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.test.tsx index f58619520432..6a6f93f63453 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.test.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.test.tsx @@ -41,7 +41,7 @@ function renderSettingsPane( - +
Colors content
diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 97277906c742..1586a5353ab0 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -39,6 +39,7 @@ import { useBookSettingsAreaDefinition } from "./BookSettingsConfigrPages"; let isOpenAlready = false; const kBookSettingsDialogWidthPx = 900; const kBookSettingsDialogHeightPx = 720; +const kConfigrPaneClassName = "book-page-settings-configr-pane"; type IPageStyle = { label: string; value: string }; type IPageStyles = Array; @@ -399,11 +400,19 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ margin-top: 0; // override the default that sees a lack of a title and adds a margin } overflow-y: hidden; + min-height: 0; - // HACK: TODO get the divs to all just maximize height until the available space is used or we don't need anymore height + .${kConfigrPaneClassName} { + height: 100%; + min-height: 0; + } + + // Let config-r consume the available dialog height in both the page form and + // the area-description states so the button row stays pinned to the bottom. form { overflow-y: auto; - height: 600px; + height: 100%; + min-height: 0; width: 100%; box-sizing: border-box; #groups { @@ -418,6 +427,7 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ > {configrInitialValues && ( void; }): IPageSettingsAreaDefinition => { - const pageAreaLabel = useL10n("Page", "BookAndPageSettings.PageArea"); + const pageAreaLabel = useL10n( + "Current Page", + "BookAndPageSettings.PageArea", + ); const colorsPageLabel = useL10n("Colors", "BookAndPageSettings.Colors"); const pageAreaDescription = useL10n( "Page settings apply to the current page.", diff --git a/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx index d50d510eefae..c9025971eeb3 100644 --- a/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx @@ -7,8 +7,8 @@ import { DragActivityTabControl, kIdForDragActivityTabControl, } from "../toolbox/games/DragActivityTabControl"; -import { getWorkspaceBundleExports } from "./workspaceFrames"; import { CogIcon } from "./CogIcon"; +import { getWorkspaceBundleExports } from "./workspaceFrames"; interface IAbovePageControlsState { isGamePage: boolean; @@ -147,7 +147,7 @@ const AbovePageControls: React.FunctionComponent = ( align-items: center; `} > - + {/* */}
= ( align-items: center; `} > - + {/* */}
Date: Mon, 30 Mar 2026 14:54:05 -0600 Subject: [PATCH 34/65] Update .github/prompts/bloom-l10.prompt.md Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .github/prompts/bloom-l10.prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/prompts/bloom-l10.prompt.md b/.github/prompts/bloom-l10.prompt.md index 5d8b1668bf86..117895643979 100644 --- a/.github/prompts/bloom-l10.prompt.md +++ b/.github/prompts/bloom-l10.prompt.md @@ -37,7 +37,7 @@ the string id may be used by translators as they try to understand context or tr Add a note like this: `ID: LinkTargetChooser.URL.Paste.Tooltip` ## Legacy strings -Our localization build system is such that if we are no longer using a string ID in the next version, we cannot remove the ID from the XLF file immediately. This is because if we release a new version of the previous release, We will still need that old localization ID. The fact that its code base still has it will not be sufficient. Somehow the actual crowd in database will Lose the translations when it sees the string ID removed from a newer version. Therefore when we stop using a string ID we just add a not like this: "Obsolete as of 6.2". You can figure out the current version from the `Version` property on build/Bloom.proj. +Our localization build system is such that if we are no longer using a string ID in the next version, we cannot remove the ID from the XLF file immediately. This is because if we release a new version of the previous release, we will still need that old localization ID. The fact that its code base still has it will not be sufficient. Somehow the actual Crowdin database will lose the translations when it sees the string ID removed from a newer version. Therefore when we stop using a string ID we just add a note like this: "Obsolete as of 6.2". You can figure out the current version from the `Version` property on build/Bloom.proj. ## Add comments for translators Although we don't want to fill in l10nComment in useL10n, we do want to fill in the note field to give context to translators. They don't know where the string appears in the UI, they also might need some explanation of what it means. For example, for the above string, we might add a note like `This is the text on a button in the Foobar dialog that brightens all images in the current book.` From 53173eccf01c4d0effb94975deca074087db34f0 Mon Sep 17 00:00:00 2001 From: John Hatton Date: Mon, 30 Mar 2026 14:55:25 -0600 Subject: [PATCH 35/65] Update .github/skills/bloom-automation/SKILL.md Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .github/skills/bloom-automation/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/skills/bloom-automation/SKILL.md b/.github/skills/bloom-automation/SKILL.md index 0e9e60253881..2d4283b41b13 100644 --- a/.github/skills/bloom-automation/SKILL.md +++ b/.github/skills/bloom-automation/SKILL.md @@ -246,4 +246,5 @@ Report: ## Example Prompts - `troubleshoot why the page is refreshing when we open page settings` -## Debugging tips: use node or bash scripts. Avoid powershell. Use the "dev-browser" cli instead of playwright for interactive debugging/driving Bloom. Use "dev-browser --help" to see the available commands and options. If the user hasn't installe dev-browser, as them for permission to install it (https://github.com/SawyerHood/dev-browser). +## Debugging tips +Use node or bash scripts. Avoid powershell. Use the "dev-browser" cli instead of playwright for interactive debugging/driving Bloom. Use "dev-browser --help" to see the available commands and options. If the user hasn't installed dev-browser, ask them for permission to install it (https://github.com/SawyerHood/dev-browser). From 09bcf50922acae8914684a894bd198350e37045c Mon Sep 17 00:00:00 2001 From: "cubic-dev-ai[bot]" <1082092+cubic-dev-ai[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:01:29 +0000 Subject: [PATCH 36/65] fix: extract `size` prop before spreading onto SvgIcon DOM element Destructure `size` and `sx` from props, spreading only the remaining props (`rest`) onto SvgIcon. This prevents the custom `size` prop from leaking to the underlying `` DOM element and producing a React console warning. --- src/BloomBrowserUI/bookEdit/js/CogIcon.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx b/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx index 4ce82f4bc170..f51237ddd0cc 100644 --- a/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx @@ -3,13 +3,13 @@ import { SvgIcon, SvgIconProps } from "@mui/material"; export const CogIcon: React.FunctionComponent< SvgIconProps & { size?: number | string } -> = (props) => ( +> = ({ size, sx, ...rest }) => ( From b6cf39af55b069ae792e0423fc68a3bf14e81bfc Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 15:04:49 -0600 Subject: [PATCH 37/65] docs: fix react-useeffect resource count --- .github/skills/react-useeffect/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/skills/react-useeffect/README.md b/.github/skills/react-useeffect/README.md index 490ad4bae14f..6591ab8cab3d 100644 --- a/.github/skills/react-useeffect/README.md +++ b/.github/skills/react-useeffect/README.md @@ -32,7 +32,7 @@ Use this skill when you're: ## How It Works -This skill provides guidance through three key resources: +This skill provides guidance through four key resources: 1. **Quick Reference Table** - Fast lookup for common scenarios with DO/DON'T patterns 2. **Decision Tree** - Visual flowchart to determine the right approach From 437def86ffc2bd4a5031d40ed5a7e945004c1bd2 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 15:04:57 -0600 Subject: [PATCH 38/65] docs: correct bloom l10n prompt typos --- .github/prompts/bloom-l10.prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/prompts/bloom-l10.prompt.md b/.github/prompts/bloom-l10.prompt.md index 5d8b1668bf86..5af9dc30b8a9 100644 --- a/.github/prompts/bloom-l10.prompt.md +++ b/.github/prompts/bloom-l10.prompt.md @@ -37,7 +37,7 @@ the string id may be used by translators as they try to understand context or tr Add a note like this: `ID: LinkTargetChooser.URL.Paste.Tooltip` ## Legacy strings -Our localization build system is such that if we are no longer using a string ID in the next version, we cannot remove the ID from the XLF file immediately. This is because if we release a new version of the previous release, We will still need that old localization ID. The fact that its code base still has it will not be sufficient. Somehow the actual crowd in database will Lose the translations when it sees the string ID removed from a newer version. Therefore when we stop using a string ID we just add a not like this: "Obsolete as of 6.2". You can figure out the current version from the `Version` property on build/Bloom.proj. +Our localization build system is such that if we are no longer using a string ID in the next version, we cannot remove the ID from the XLF file immediately. This is because if we release a new version of the previous release, We will still need that old localization ID. The fact that its code base still has it will not be sufficient. Somehow the actual Crowdin database will lose the translations when it sees the string ID removed from a newer version. Therefore when we stop using a string ID we just add a not like this: "Obsolete as of 6.2". You can figure out the current version from the `Version` property on build/Bloom.proj. ## Add comments for translators Although we don't want to fill in l10nComment in useL10n, we do want to fill in the note field to give context to translators. They don't know where the string appears in the UI, they also might need some explanation of what it means. For example, for the above string, we might add a note like `This is the text on a button in the Foobar dialog that brightens all images in the current book.` From 644166611b7226169dce415c9e77181a5139f308 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 15:05:04 -0600 Subject: [PATCH 39/65] docs: fix bloom automation wording --- .github/skills/bloom-automation/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/skills/bloom-automation/SKILL.md b/.github/skills/bloom-automation/SKILL.md index 0e9e60253881..ee09608eeb94 100644 --- a/.github/skills/bloom-automation/SKILL.md +++ b/.github/skills/bloom-automation/SKILL.md @@ -246,4 +246,4 @@ Report: ## Example Prompts - `troubleshoot why the page is refreshing when we open page settings` -## Debugging tips: use node or bash scripts. Avoid powershell. Use the "dev-browser" cli instead of playwright for interactive debugging/driving Bloom. Use "dev-browser --help" to see the available commands and options. If the user hasn't installe dev-browser, as them for permission to install it (https://github.com/SawyerHood/dev-browser). +## Debugging tips: use node or bash scripts. Avoid powershell. Use the "dev-browser" cli instead of playwright for interactive debugging/driving Bloom. Use "dev-browser --help" to see the available commands and options. If the user hasn't installed dev-browser, ask them for permission to install it (https://github.com/SawyerHood/dev-browser). From 703bea36c7bed7883e50b967bf68af3465ed0d2d Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 15:05:11 -0600 Subject: [PATCH 40/65] fix: stop CogIcon size prop leaking --- src/BloomBrowserUI/bookEdit/js/CogIcon.tsx | 29 +++++++++++++--------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx b/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx index 4ce82f4bc170..15e931719018 100644 --- a/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx @@ -3,15 +3,20 @@ import { SvgIcon, SvgIconProps } from "@mui/material"; export const CogIcon: React.FunctionComponent< SvgIconProps & { size?: number | string } -> = (props) => ( - - - -); +> = (props) => { + const svgIconProps = { ...props }; + delete svgIconProps.size; + + return ( + + + + ); +}; From f1077b2c62b0d751b53cdd030f05b58712dc3a05 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 15:05:20 -0600 Subject: [PATCH 41/65] fix: localize color picker sample title --- DistFiles/localization/en/Bloom.xlf | 5 +++++ .../react_components/color-picking/colorPicker.tsx | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/DistFiles/localization/en/Bloom.xlf b/DistFiles/localization/en/Bloom.xlf index 55a002e44f22..80539c9428f3 100644 --- a/DistFiles/localization/en/Bloom.xlf +++ b/DistFiles/localization/en/Bloom.xlf @@ -914,6 +914,11 @@ ID: ColorPicker.New A background color selection that enables a color picker. + + Sample Color + ID: ColorPicker.SampleColor + Tooltip text on the eyedropper button that samples a color from the page. + Book Settings ID: Common.BookSettings diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx index 3b7fcc079d5e..59937d463e14 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx @@ -144,6 +144,7 @@ export const ColorPicker: React.FunctionComponent = ( "Default for style", "EditTab.DirectFormatting.labelForDefaultColor", ); + const sampleColorTitle = useL10n("Sample Color", "ColorPicker.SampleColor"); const defaultButtonLabel = props.defaultButtonLabel ?? defaultStyleLabel; const cloneColor = (color: IColorInfo): IColorInfo => { @@ -319,7 +320,7 @@ export const ColorPicker: React.FunctionComponent = ( {hasNativeEyedropper && ( Date: Mon, 30 Mar 2026 15:05:56 -0600 Subject: [PATCH 42/65] test: reset head in page settings spec --- .../PageSettingsConfigrPages.spec.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts index 5b7dc924b057..b89e1182618d 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts @@ -11,6 +11,7 @@ import { describe("PageSettingsConfigrPages", () => { beforeEach(() => { + document.head.innerHTML = ""; document.body.innerHTML = '
'; }); @@ -39,9 +40,14 @@ describe("PageSettingsConfigrPages", () => { }); it("preserves the themed outer page background when applying a page background color", () => { + document.head.innerHTML = ``; const page = document.body.querySelector(".bloom-page") as HTMLElement; - page.style.setProperty("--page-background-color", "#2e2e2e"); - page.style.setProperty("--marginBox-background-color", "#ffffff"); applyPageSettings({ page: { @@ -55,15 +61,19 @@ describe("PageSettingsConfigrPages", () => { expect( page.style.getPropertyValue("--marginBox-background-color"), ).toBe("#ABCDEF"); - expect(page.style.getPropertyValue("--page-background-color")).toBe( - "#2e2e2e", - ); + expect(page.style.getPropertyValue("--page-background-color")).toBe(""); }); it("updates both page and margin box colors when the theme uses one flat background", () => { + document.head.innerHTML = ``; const page = document.body.querySelector(".bloom-page") as HTMLElement; - page.style.setProperty("--page-background-color", "#ffffff"); - page.style.setProperty("--marginBox-background-color", "#ffffff"); + page.style.setProperty("--marginBox-background-color", "#fedcba"); applyPageSettings({ page: { From e183d8d5b6e6960ad3552572b2ae44204ede93d9 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 15:18:02 -0600 Subject: [PATCH 43/65] localization --- DistFiles/localization/en/BloomMediumPriority.xlf | 5 ----- .../CollectionTopBarControls/CollectionTopBarControls.tsx | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index f9164e8dc397..88b6ba12d78f 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -753,11 +753,6 @@ ID: BookAndPageSettings.PageArea Area label for tabs/pages that affect only the current page. - - Page settings apply to the current page. - ID: BookAndPageSettings.PageArea.Description - Description text shown for the Page area in the combined Book and Page Settings dialog. - Colors ID: BookAndPageSettings.Colors diff --git a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx index 2fc8bd56421d..b30381efc991 100644 --- a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx +++ b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx @@ -70,8 +70,8 @@ export const CollectionTopBarControls: React.FunctionComponent = () => { > Date: Mon, 30 Mar 2026 16:03:56 -0600 Subject: [PATCH 44/65] fixes for different themes --- .../BookAndPageSettingsDialog.tsx | 16 ++++- .../PageSettingsConfigrPages.spec.ts | 58 +++++++++++++++ .../PageSettingsConfigrPages.tsx | 71 ++++++++++--------- src/content/bookLayout/basePage.less | 52 ++++++++++++++ 4 files changed, 164 insertions(+), 33 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 1586a5353ab0..0afd6d117828 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -73,6 +73,16 @@ interface IOverrideInformation { xmatterName: string; } +const getThemeNameFromConfigrSettings = ( + settings: ConfigrValues | undefined, +): string | undefined => { + const appearance = settings?.["appearance"] as + | Record + | undefined; + const cssThemeName = appearance?.["cssThemeName"]; + return typeof cssThemeName === "string" ? cssThemeName : undefined; +}; + export const BookAndPageSettingsDialog: React.FunctionComponent<{ initiallySelectedPageKey?: string; }> = (props) => { @@ -312,6 +322,7 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ if (latestSettings) { applyPageSettings( parsePageSettingsFromConfigrValue(latestSettings), + getThemeNameFromConfigrSettings(latestSettings), ); const settingsToPost = @@ -459,7 +470,10 @@ export const BookAndPageSettingsDialog: React.FunctionComponent<{ return; } - applyPageSettings(parsedPageSettings); + applyPageSettings( + parsedPageSettings, + getThemeNameFromConfigrSettings(s), + ); }} initiallySelectedTopLevelPageKey={ initiallySelectedConfigrPageKey diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts index b89e1182618d..a28ef7ba7373 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts @@ -91,4 +91,62 @@ describe("PageSettingsConfigrPages", () => { "#ABCDEF", ); }); + + it("uses the target default theme semantics during a theme change", () => { + document.head.innerHTML = ``; + const page = document.body.querySelector(".bloom-page") as HTMLElement; + + applyPageSettings( + { + page: { + backgroundColor: "#ABCDEF", + pageNumberColor: "#000000", + pageNumberOutlineColor: "transparent", + pageNumberBackgroundColor: "transparent", + }, + }, + "default", + ); + + expect( + page.style.getPropertyValue("--marginBox-background-color"), + ).toBe("#ABCDEF"); + expect(page.style.getPropertyValue("--page-background-color")).toBe( + "#ABCDEF", + ); + }); + + it("uses the target rounded theme semantics during a theme change", () => { + document.head.innerHTML = ``; + const page = document.body.querySelector(".bloom-page") as HTMLElement; + + applyPageSettings( + { + page: { + backgroundColor: "#ABCDEF", + pageNumberColor: "#000000", + pageNumberOutlineColor: "transparent", + pageNumberBackgroundColor: "transparent", + }, + }, + "rounded-border-ebook", + ); + + expect( + page.style.getPropertyValue("--marginBox-background-color"), + ).toBe("#ABCDEF"); + expect(page.style.getPropertyValue("--page-background-color")).toBe(""); + }); }); diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx index 2f31705a9445..022c97baa71f 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx @@ -35,6 +35,8 @@ export const getCurrentPageElement = (): HTMLElement => { }; const kTransparentCssValue = "transparent"; +const kUnifiedBackgroundThemes = new Set(["default", "zero-margin-ebook"]); +const kSeparatedBackgroundThemes = new Set(["rounded-border-ebook"]); const normalizeToHexOrEmpty = (color: string): string => { const trimmed = color.trim(); @@ -127,36 +129,32 @@ const getCurrentPageBackgroundColor = (): string => { return computedBackground || "#FFFFFF"; }; -const getEffectivePageBackgroundColor = (page: HTMLElement): string => { - const computedPage = getComputedStyleForPage(page); - const computedVariable = normalizeToHexOrEmpty( - computedPage.getPropertyValue("--page-background-color"), - ); - if (computedVariable) return computedVariable; - - const computedBackground = normalizeToHexOrEmpty( - computedPage.backgroundColor, - ); - return computedBackground || "#FFFFFF"; -}; +const doesThemeUseUnifiedPageBackground = ( + page: HTMLElement, + targetThemeName?: string, +): boolean => { + if (targetThemeName) { + if (kUnifiedBackgroundThemes.has(targetThemeName)) { + return true; + } + + if (kSeparatedBackgroundThemes.has(targetThemeName)) { + return false; + } + } -const getEffectiveMarginBoxBackgroundColor = (page: HTMLElement): string => { const computedPage = getComputedStyleForPage(page); - const computedMarginBoxVariable = normalizeToHexOrEmpty( - computedPage.getPropertyValue("--marginBox-background-color"), + const multiplicand = Number.parseFloat( + computedPage + .getPropertyValue( + "--page-and-marginBox-are-same-color-multiplicand", + ) + .trim(), ); - if (computedMarginBoxVariable) return computedMarginBoxVariable; - const marginBox = page.querySelector(".marginBox") as HTMLElement | null; - if (marginBox) { - const computedMarginBoxBackground = normalizeToHexOrEmpty( - getComputedStyleForPage(marginBox).backgroundColor, - ); - if (computedMarginBoxBackground) return computedMarginBoxBackground; - } - - return getEffectivePageBackgroundColor(page); + return Number.isNaN(multiplicand) || multiplicand > 0; }; + const setOrRemoveCustomProperty = ( style: CSSStyleDeclaration, propertyName: string, @@ -183,19 +181,22 @@ const setOrRemoveCustomPropertyAllowTransparent = ( } }; -const setCurrentPageBackgroundColor = (color: string): void => { +const setCurrentPageBackgroundColor = ( + color: string, + targetThemeName?: string, +): void => { const page = getCurrentPageElement(); - const effectivePageBackgroundColor = getEffectivePageBackgroundColor(page); - const effectiveMarginBoxBackgroundColor = - getEffectiveMarginBoxBackgroundColor(page); + setOrRemoveCustomProperty( page.style, "--marginBox-background-color", color, ); - if (effectivePageBackgroundColor === effectiveMarginBoxBackgroundColor) { + if (doesThemeUseUnifiedPageBackground(page, targetThemeName)) { setOrRemoveCustomProperty(page.style, "--page-background-color", color); + } else { + page.style.removeProperty("--page-background-color"); } }; @@ -281,8 +282,14 @@ export const getCurrentPageSettings = (): IPageSettings => { }; }; -export const applyPageSettings = (settings: IPageSettings): void => { - setCurrentPageBackgroundColor(settings.page.backgroundColor); +export const applyPageSettings = ( + settings: IPageSettings, + targetThemeName?: string, +): void => { + setCurrentPageBackgroundColor( + settings.page.backgroundColor, + targetThemeName, + ); setPageNumberColor(settings.page.pageNumberColor); setPageNumberOutlineColor(settings.page.pageNumberOutlineColor); setPageNumberBackgroundColor(settings.page.pageNumberBackgroundColor); diff --git a/src/content/bookLayout/basePage.less b/src/content/bookLayout/basePage.less index f4b8e82620c5..1e59db85b8d1 100644 --- a/src/content/bookLayout/basePage.less +++ b/src/content/bookLayout/basePage.less @@ -950,6 +950,58 @@ The buffer would be absent if the marginBox had a border or the page has a backg padding-bottom: var(--topLevel-text-padding-bottom); } + // Theme-switch testing exposed that these horizontal-then-vertical corner cases were + // missing from the existing split-pane padding rules, so zero-margin pages lost the + // intended top-level text padding on those layouts. + & + > .split-pane.horizontal-percent + > .split-pane-component.position-top + > .split-pane.vertical-percent + > .split-pane-component.position-left + > .split-pane-component-inner + > .bloom-translationGroup { + padding-bottom: var(--topLevel-text-padding-bottom); + padding-right: var(--topLevel-text-padding-right); + .mixinLeftTgPadding; + .mixinTopTgPadding; + } + & + > .split-pane.horizontal-percent + > .split-pane-component.position-top + > .split-pane.vertical-percent + > .split-pane-component.position-right + > .split-pane-component-inner + > .bloom-translationGroup { + padding-bottom: var(--topLevel-text-padding-bottom); + padding-left: var(--topLevel-text-padding-left); + .mixinRightTgPadding; + .mixinTopTgPadding; + } + & + > .split-pane.horizontal-percent + > .split-pane-component.position-bottom + > .split-pane.vertical-percent + > .split-pane-component.position-left + > .split-pane-component-inner + > .bloom-translationGroup { + padding-top: var(--topLevel-text-padding-top); + padding-right: var(--topLevel-text-padding-right); + .mixinLeftTgPadding; + .mixinBottomTgPadding; + } + & + > .split-pane.horizontal-percent + > .split-pane-component.position-bottom + > .split-pane.vertical-percent + > .split-pane-component.position-right + > .split-pane-component-inner + > .bloom-translationGroup { + padding-top: var(--topLevel-text-padding-top); + padding-left: var(--topLevel-text-padding-left); + .mixinRightTgPadding; + .mixinBottomTgPadding; + } + // This one handles the text box at the bottom left of a Big Video Diglot. // Review: it's not obvious what to do about the outer edges here. In the simpler cases, // we DO have outer-edge padding when there is no other margin and the top-level split is vertical. From 7d7e5077a8308889274005a7012cc7df683d5d1e Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 16:17:11 -0600 Subject: [PATCH 45/65] comment --- src/BloomBrowserUI/bookEdit/js/bloomEditing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts index 3450659d7bb8..f8d9a196066d 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts @@ -1217,7 +1217,7 @@ export function localizeCkeditorTooltips(bar: JQuery) { // This is invoked when we are about to change pages. function removeEditingDebris() { resetAbovePageControls(); - // We are mirroring the changeLayoutModeToggleClickHandler() here, in case the user changes + // We are mirroring the Change Layout mode toggle behavior here, in case the user changes // pages while the Change Layout mode toggle is on. // The DOM here is for just one page, so there's only ever one marginBox. const marginBox = document.getElementsByClassName("marginBox")[0]; From 259515d6984e35aa8b687b7afd5f8f2b5b80b1e1 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 30 Mar 2026 16:47:03 -0600 Subject: [PATCH 46/65] Fix unresolved toolbox PR comments devin-ai-integration: undefined invalidatePendingJQueryAccordionActivationRetries call in toolbox.ts. Removed the stale call from ensureToolEnabled so enabling a tool no longer risks a runtime ReferenceError. devin-ai-integration: setCurrentTool retry loop had no explicit supersession mechanism. Added a single pending retry timeout plus request tokens so newer tool-activation requests cancel or invalidate older retries. --- .../bookEdit/toolbox/toolbox.ts | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts b/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts index 833f0f378377..9cfe83f7db28 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts @@ -585,7 +585,6 @@ export class ToolBox { const activeToolId = getActiveToolIdFromCurrentToolboxUi(); const checkBoxId = toolId + "Check"; beginAddTool(checkBoxId, toolIdWithTool, false, () => { - invalidatePendingJQueryAccordionActivationRetries(); const adapter = getToolboxReactAdapter(); if (adapter) { if (activeToolId) { @@ -739,6 +738,15 @@ function ensureJQueryAccordionActivationHook(toolbox: JQuery): void { const initialAccordionInitializationRetryDelayInMilliseconds = 10; const maximumAccordionInitializationRetryDelayInMilliseconds = 50; const maxMillisecondsToWaitForAccordionInitialization = 3000; +let pendingSetCurrentToolRetryTimeout: number | undefined; +let latestSetCurrentToolRequestToken = 0; + +function clearPendingSetCurrentToolRetry(): void { + if (pendingSetCurrentToolRetryTimeout !== undefined) { + window.clearTimeout(pendingSetCurrentToolRetryTimeout); + pendingSetCurrentToolRetryTimeout = undefined; + } +} // This primarily calls the detachFromPage method of the current tool, if any. // It also tries to find the current toolbox instance (in the right iframe, wherever it is called), @@ -1115,7 +1123,16 @@ function setCurrentTool( toolID: string, retryCount = 0, waitStartTime = Date.now(), + requestToken?: number, ) { + const currentRequestToken = + requestToken ?? ++latestSetCurrentToolRequestToken; + if (requestToken === undefined) { + clearPendingSetCurrentToolRetry(); + } else if (currentRequestToken !== latestSetCurrentToolRequestToken) { + return; + } + // I'm downright grumpy about how this code sometimes uses names with "Tool" appended, sometimes doesn't. // For now I'm just making functions work with either form. toolID = ToolBox.addToolToString(toolID); @@ -1170,10 +1187,15 @@ function setCurrentTool( ); // A 0ms retry loop can burn through the whole retry budget before jQuery finishes // initializing on a slow startup, so back off a little and cap the total wait time. - window.setTimeout( - () => setCurrentTool(toolID, retryCount + 1, waitStartTime), - retryDelay, - ); + pendingSetCurrentToolRetryTimeout = window.setTimeout(() => { + pendingSetCurrentToolRetryTimeout = undefined; + setCurrentTool( + toolID, + retryCount + 1, + waitStartTime, + currentRequestToken, + ); + }, retryDelay); return; } From f283c8f5bd726fceed6088998935238c421fa9bb Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 31 Mar 2026 08:41:26 -0600 Subject: [PATCH 47/65] update automation to the new react-window layout --- .gitignore | 2 ++ .../bloom-exe-collection-topbar.uitest.ts | 10 +++---- .../component-tests/bloom-exe-tabs.uitest.ts | 17 +++++------ .../component-tester/bloomExeCdp.ts | 28 +++++++++++-------- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 60a48e184ee6..b12e32ca1abe 100644 --- a/.gitignore +++ b/.gitignore @@ -199,3 +199,5 @@ src/BloomBrowserUI/react_components/component-tester/test-results/ src/BloomBrowserUI/test-results/ critiqueAI.json + +*.stackdump diff --git a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/component-tests/bloom-exe-collection-topbar.uitest.ts b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/component-tests/bloom-exe-collection-topbar.uitest.ts index 169c94562f7b..0d4154d87eff 100644 --- a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/component-tests/bloom-exe-collection-topbar.uitest.ts +++ b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/component-tests/bloom-exe-collection-topbar.uitest.ts @@ -1,7 +1,7 @@ import { test, expect } from "../../../component-tester/playwrightTest"; import { + clickWorkspaceTab, connectToBloomExe, - getBloomTopBarFrame, waitForActiveWorkspaceTab, } from "../../../component-tester/bloomExeCdp"; @@ -10,16 +10,14 @@ test.describe("CollectionTopBarControls on Bloom.exe", () => { const connection = await connectToBloomExe(); try { - const topBarFrame = await getBloomTopBarFrame(connection.page); - - await topBarFrame.getByRole("tab", { name: "Collections" }).click(); + await clickWorkspaceTab(connection.page, "Collections"); await waitForActiveWorkspaceTab("collection"); await expect( - topBarFrame.getByText("Settings", { exact: true }), + connection.page.getByText("Settings", { exact: true }), ).toBeVisible(); await expect( - topBarFrame.getByText("Other Collection", { exact: true }), + connection.page.getByText("Other Collection", { exact: true }), ).toBeVisible(); } finally { await connection.browser.close(); diff --git a/src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts b/src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts index a0b013072a93..dfd91b2f8dd5 100644 --- a/src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts +++ b/src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts @@ -1,30 +1,28 @@ import { test, expect } from "../../component-tester/playwrightTest"; import { + clickWorkspaceTab, connectToBloomExe, - getBloomTopBarFrame, waitForActiveWorkspaceTab, } from "../../component-tester/bloomExeCdp"; test.describe("Bloom exe CDP top bar", () => { - test("switches embedded workspace tabs through the real top bar iframe", async () => { + test("switches embedded workspace tabs through the real top bar", async () => { const connection = await connectToBloomExe(); try { - const topBarFrame = await getBloomTopBarFrame(connection.page); - - await topBarFrame.getByRole("tab", { name: "Collections" }).click(); + await clickWorkspaceTab(connection.page, "Collections"); await waitForActiveWorkspaceTab("collection"); await expect(connection.page.locator("body")).toHaveClass( /collection-mode/, ); - await topBarFrame.getByRole("tab", { name: "Publish" }).click(); + await clickWorkspaceTab(connection.page, "Publish"); await waitForActiveWorkspaceTab("publish"); await expect(connection.page.locator("body")).toHaveClass( /publish-mode/, ); - await topBarFrame.getByRole("tab", { name: "Edit" }).click(); + await clickWorkspaceTab(connection.page, "Edit"); await waitForActiveWorkspaceTab("edit"); await expect(connection.page.locator("body")).toHaveClass( /edit-mode/, @@ -38,7 +36,6 @@ test.describe("Bloom exe CDP top bar", () => { const connection = await connectToBloomExe(); try { - const topBarFrame = await getBloomTopBarFrame(connection.page); const consoleMessages: string[] = []; const requestUrls: string[] = []; @@ -59,7 +56,7 @@ test.describe("Bloom exe CDP top bar", () => { ) .toBe(true); - await topBarFrame.getByRole("tab", { name: "Publish" }).click(); + await clickWorkspaceTab(connection.page, "Publish"); await waitForActiveWorkspaceTab("publish"); await expect @@ -70,7 +67,7 @@ test.describe("Bloom exe CDP top bar", () => { ) .toBe(true); - await topBarFrame.getByRole("tab", { name: "Edit" }).click(); + await clickWorkspaceTab(connection.page, "Edit"); await waitForActiveWorkspaceTab("edit"); } finally { await connection.browser.close(); diff --git a/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts b/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts index c30090fb3a44..5c6ff338362e 100644 --- a/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts +++ b/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts @@ -1,4 +1,4 @@ -import { Browser, Frame, Page, chromium } from "./playwrightTest"; +import { Browser, Page, chromium } from "./playwrightTest"; type WorkspaceTabId = "collection" | "edit" | "publish"; const configuredCdpPort = process.env.BLOOM_CDP_PORT; @@ -66,19 +66,23 @@ export const connectToBloomExe = async (): Promise<{ return { browser, page }; }; -export const getBloomTopBarFrame = async (page: Page): Promise => { - const topBarHandle = await page.$("#topBar"); - if (!topBarHandle) { - throw new Error("Could not find the Bloom topBar iframe."); - } +export const clickWorkspaceTab = async ( + page: Page, + name: WorkspaceTabId extends infer _T + ? "Collections" | "Edit" | "Publish" + : never, +): Promise => { + await page.waitForSelector("#main-tabs button", { + timeout: 10000, + }); - const frame = await topBarHandle.contentFrame(); - if (!frame) { - throw new Error("The Bloom topBar iframe did not expose a frame."); - } + await page.locator("#main-tabs button").filter({ hasText: name }).first(); - await frame.waitForLoadState("domcontentloaded"); - return frame; + await page + .locator("#main-tabs button") + .filter({ hasText: name }) + .first() + .click(); }; export const getWorkspaceTabs = async (): Promise<{ From 62bf013311dd11ea7bc6ceb1d1c0910459e538f0 Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 31 Mar 2026 08:53:47 -0600 Subject: [PATCH 48/65] Inherit the running Bloom Vite port in watch mode. Report the active Vite dev-server port in instance metadata. Preserve that metadata in the Bloom automation helpers. Allow watchBloomExe to reuse the Vite port from a running Bloom instance in the same worktree. --- .../bloom-automation/bloomProcessCommon.mjs | 6 +++ scripts/watchBloomExe.mjs | 51 +++++++++++++++++-- src/BloomExe/web/controllers/CommonApi.cs | 4 ++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/.github/skills/bloom-automation/bloomProcessCommon.mjs b/.github/skills/bloom-automation/bloomProcessCommon.mjs index c2b4197976e1..9da076110fe5 100644 --- a/.github/skills/bloom-automation/bloomProcessCommon.mjs +++ b/.github/skills/bloom-automation/bloomProcessCommon.mjs @@ -107,6 +107,9 @@ export const extractRepoRoot = (text) => { export const normalizeBloomInstanceInfo = (info, discoveredViaPort) => { const httpPort = toTcpPort(info?.httpPort) ?? discoveredViaPort; const cdpPort = toTcpPort(info?.cdpPort); + const executablePath = normalizePath(info?.executablePath); + const detectedRepoRoot = extractRepoRoot(executablePath); + const vitePort = toTcpPort(info?.vitePort); return { processId: toPositiveInteger(info?.processId), @@ -114,6 +117,9 @@ export const normalizeBloomInstanceInfo = (info, discoveredViaPort) => { httpPort, origin: toLocalOrigin(httpPort), cdpPort, + executablePath, + detectedRepoRoot, + vitePort, }; }; diff --git a/scripts/watchBloomExe.mjs b/scripts/watchBloomExe.mjs index 35a4ab603bc7..0b583c999bee 100644 --- a/scripts/watchBloomExe.mjs +++ b/scripts/watchBloomExe.mjs @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { + findRunningStandardBloomInstances, requireOptionValue, requireTcpPortOption, } from "../.github/skills/bloom-automation/bloomProcessCommon.mjs"; @@ -83,6 +84,42 @@ if (!existsSync(projectPath)) { process.exit(1); } +const normalizeComparablePath = (value) => + path.resolve(value).replace(/\//g, "\\").toLowerCase(); + +const tryInferVitePortFromRunningBloom = async () => { + const runningInstances = await findRunningStandardBloomInstances(); + const expectedRepoRoot = normalizeComparablePath(options.repoRoot); + const vitePorts = [ + ...new Set( + runningInstances + .filter( + (instance) => + instance.detectedRepoRoot && + normalizeComparablePath(instance.detectedRepoRoot) === + expectedRepoRoot && + instance.vitePort, + ) + .map((instance) => instance.vitePort), + ), + ]; + + if (vitePorts.length === 1) { + return vitePorts[0]; + } + + if (vitePorts.length > 1) { + console.warn( + `Multiple running Bloom instances from this worktree reported different Vite ports (${vitePorts.join(", ")}). Launching without an inherited Vite port.`, + ); + } + + return undefined; +}; + +const effectiveVitePort = + options.vitePort ?? (await tryInferVitePortFromRunningBloom()); + const dotnetArgs = [ "watch", "run", @@ -98,12 +135,18 @@ if (startupLabel) { dotnetArgs.push("--label", startupLabel); } -if (options.vitePort) { - dotnetArgs.push("--vite-port", String(options.vitePort)); +if (effectiveVitePort) { + dotnetArgs.push("--vite-port", String(effectiveVitePort)); } -if (options.vitePort) { - console.log(`Bloom Vite dev port: ${options.vitePort}`); +if (effectiveVitePort) { + if (options.vitePort) { + console.log(`Bloom Vite dev port: ${effectiveVitePort}`); + } else { + console.log( + `Inherited Bloom Vite dev port from running worktree instance: ${effectiveVitePort}`, + ); + } } const createForwardingLineWriter = (target, onLine) => { diff --git a/src/BloomExe/web/controllers/CommonApi.cs b/src/BloomExe/web/controllers/CommonApi.cs index 9e327388e667..a57f47e5b894 100644 --- a/src/BloomExe/web/controllers/CommonApi.cs +++ b/src/BloomExe/web/controllers/CommonApi.cs @@ -237,6 +237,9 @@ private void HandleInstanceInfo(ApiRequest request) var executablePath = Application.ExecutablePath; var cdpPort = Bloom.WebView2Browser.RemoteDebuggingPort; + int? vitePort = ReactControl.TryGetActiveViteDevPort(out var activeVitePort) + ? activeVitePort + : null; request.ReplyWithJson( new { @@ -251,6 +254,7 @@ private void HandleInstanceInfo(ApiRequest request) workspaceTabsUrl = BloomServer.ServerUrlWithBloomPrefixEndingInSlash + "api/workspace/tabs", cdpPort, + vitePort, cdpOrigin = cdpPort.HasValue ? $"http://localhost:{cdpPort.Value}" : null, } ); From 16a746a4327d1130cade888b8ac5c39acd8550c2 Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 31 Mar 2026 11:23:17 -0600 Subject: [PATCH 49/65] Stop keeping track of normal themes when it comes to knowing that they have a special order. --- .../PageSettingsConfigrPages.spec.ts | 30 +++++++++++++++++++ .../PageSettingsConfigrPages.tsx | 7 ++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts index a28ef7ba7373..ee0e083ebadc 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.spec.ts @@ -149,4 +149,34 @@ describe("PageSettingsConfigrPages", () => { ).toBe("#ABCDEF"); expect(page.style.getPropertyValue("--page-background-color")).toBe(""); }); + + it("treats unspecified target themes as unified during a theme change", () => { + document.head.innerHTML = ``; + const page = document.body.querySelector(".bloom-page") as HTMLElement; + + applyPageSettings( + { + page: { + backgroundColor: "#ABCDEF", + pageNumberColor: "#000000", + pageNumberOutlineColor: "transparent", + pageNumberBackgroundColor: "transparent", + }, + }, + "narrow-margin-ebook", + ); + + expect( + page.style.getPropertyValue("--marginBox-background-color"), + ).toBe("#ABCDEF"); + expect(page.style.getPropertyValue("--page-background-color")).toBe( + "#ABCDEF", + ); + }); }); diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx index 022c97baa71f..7fa32c98a3a6 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx @@ -35,7 +35,6 @@ export const getCurrentPageElement = (): HTMLElement => { }; const kTransparentCssValue = "transparent"; -const kUnifiedBackgroundThemes = new Set(["default", "zero-margin-ebook"]); const kSeparatedBackgroundThemes = new Set(["rounded-border-ebook"]); const normalizeToHexOrEmpty = (color: string): string => { @@ -134,13 +133,11 @@ const doesThemeUseUnifiedPageBackground = ( targetThemeName?: string, ): boolean => { if (targetThemeName) { - if (kUnifiedBackgroundThemes.has(targetThemeName)) { - return true; - } - if (kSeparatedBackgroundThemes.has(targetThemeName)) { return false; } + + return true; } const computedPage = getComputedStyleForPage(page); From da803e92c61e2d13ac9b474e4d50b9dac68a9ba4 Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 2 Apr 2026 14:10:18 -0600 Subject: [PATCH 50/65] Revert unrelated toolbox.ts changes --- .../bookEdit/toolbox/toolbox.ts | 97 +++---------------- 1 file changed, 13 insertions(+), 84 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts b/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts index 9cfe83f7db28..c0730340455f 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts @@ -676,7 +676,6 @@ export function getMasterToolList() { const masterToolList: ITool[] = []; let currentTool: ITool | undefined = undefined; let toolboxReactActivationHooked = false; -let jQueryAccordionActivationHooked = false; // The AI decided to create this react adapter object and save in in a window variable. // It gets set in a useEffect in the React component that is the root of the toolbox. @@ -714,40 +713,6 @@ function getActiveToolIdFromCurrentToolboxUi(): string | undefined { return activeHeader?.getAttribute("data-toolId") || undefined; } -function isAccordionInitialized(toolboxElement: JQuery): boolean { - return toolboxElement.hasClass("ui-accordion"); -} - -function ensureJQueryAccordionActivationHook(toolbox: JQuery): void { - if (jQueryAccordionActivationHooked) { - return; - } - - // Even if startup gives up waiting for the accordion, later user clicks still need - // to flow through switchTool() once the accordion finishes initializing. - toolbox.onSafe("accordionactivate.toolbox", (event, ui) => { - let newToolName = ""; - if (ui.newHeader.attr("data-toolId")) { - newToolName = ui.newHeader.attr("data-toolId").toString(); - } - switchTool(newToolName); - }); - jQueryAccordionActivationHooked = true; -} - -const initialAccordionInitializationRetryDelayInMilliseconds = 10; -const maximumAccordionInitializationRetryDelayInMilliseconds = 50; -const maxMillisecondsToWaitForAccordionInitialization = 3000; -let pendingSetCurrentToolRetryTimeout: number | undefined; -let latestSetCurrentToolRequestToken = 0; - -function clearPendingSetCurrentToolRetry(): void { - if (pendingSetCurrentToolRetryTimeout !== undefined) { - window.clearTimeout(pendingSetCurrentToolRetryTimeout); - pendingSetCurrentToolRetryTimeout = undefined; - } -} - // This primarily calls the detachFromPage method of the current tool, if any. // It also tries to find the current toolbox instance (in the right iframe, wherever it is called), // and runs any cleanup tasks that have been registered for when closing the tool. @@ -1119,20 +1084,7 @@ async function activateToolInternalAsync( * This function attempts to activate the tool whose "data-toolId" attribute is equal to the value * of "currentTool" (the last tool displayed). */ -function setCurrentTool( - toolID: string, - retryCount = 0, - waitStartTime = Date.now(), - requestToken?: number, -) { - const currentRequestToken = - requestToken ?? ++latestSetCurrentToolRequestToken; - if (requestToken === undefined) { - clearPendingSetCurrentToolRetry(); - } else if (currentRequestToken !== latestSetCurrentToolRequestToken) { - return; - } - +function setCurrentTool(toolID: string) { // I'm downright grumpy about how this code sometimes uses names with "Tool" appended, sometimes doesn't. // For now I'm just making functions work with either form. toolID = ToolBox.addToolToString(toolID); @@ -1163,41 +1115,6 @@ function setCurrentTool( // NOTE: tools without a "data-toolId" attribute (such as the More tool) cannot be the "currentTool." let idx = 0; const toolbox = $("#toolbox"); - ensureJQueryAccordionActivationHook(toolbox); - - if (!isAccordionInitialized(toolbox)) { - const elapsedTime = Date.now() - waitStartTime; - if (elapsedTime >= maxMillisecondsToWaitForAccordionInitialization) { - const fallbackToolId = - (toolbox.find("> h3").first().attr("data-toolId") as - | string - | undefined) ?? toolID; - console.error( - `Toolbox accordion did not initialize within ${elapsedTime}ms while activating ${toolID || "the default tool"}. Falling back without waiting for the accordion UI.`, - ); - if (fallbackToolId) { - switchTool(fallbackToolId); - } - return; - } - const retryDelay = Math.min( - initialAccordionInitializationRetryDelayInMilliseconds * - (retryCount + 1), - maximumAccordionInitializationRetryDelayInMilliseconds, - ); - // A 0ms retry loop can burn through the whole retry budget before jQuery finishes - // initializing on a slow startup, so back off a little and cap the total wait time. - pendingSetCurrentToolRetryTimeout = window.setTimeout(() => { - pendingSetCurrentToolRetryTimeout = undefined; - setCurrentTool( - toolID, - retryCount + 1, - waitStartTime, - currentRequestToken, - ); - }, retryDelay); - return; - } const accordionHeaders = toolbox.find("> h3"); if (toolID) { @@ -1236,6 +1153,18 @@ function setCurrentTool( // turn animation back on toolbox.accordion("option", "animate", ani); + // when a tool is activated, save its data-toolId so state can be restored when Bloom is restarted. + // We do this after we actually set the initial tool, because setting the intial tool may not CHANGE + // the active tool (if it's already the one we want, typically the first), so we can't rely on + // the activate event happening in the initial call. Instead, we make SURE to call it for the + // tool we are making active. + toolbox.onSafe("accordionactivate.toolbox", (event, ui) => { + let newToolName = ""; + if (ui.newHeader.attr("data-toolId")) { + newToolName = ui.newHeader.attr("data-toolId").toString(); + } + switchTool(newToolName); + }); //alert("switching to " + currentTool + " which has index " + toolIndex); //setTimeout(e => switchTool(currentTool), 700); switchTool(toolID); From b2a44b53e652a78d22b67bb9fdfc97c40d4e38f7 Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 2 Apr 2026 14:13:59 -0600 Subject: [PATCH 51/65] Match page label typography in edit bar --- src/BloomBrowserUI/bookEdit/css/editMode.less | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/BloomBrowserUI/bookEdit/css/editMode.less b/src/BloomBrowserUI/bookEdit/css/editMode.less index 9285a82a8598..671899fa8208 100644 --- a/src/BloomBrowserUI/bookEdit/css/editMode.less +++ b/src/BloomBrowserUI/bookEdit/css/editMode.less @@ -351,6 +351,10 @@ body:has(#canvas-element-context-controls:hover) .bloom-page, left: 5mm; top: @PageLabelVerticalDisplacement; float: left; + font-family: @UIFontStack; + font-size: 9pt; + line-height: 16px; + font-weight: 400; &[contenteditable="true"] { color: @ControlColor; From cb0e7c7e2785670b8336d65d980365d5c289bad2 Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 2 Apr 2026 14:15:13 -0600 Subject: [PATCH 52/65] review fixes --- .../PageSettingsConfigrPages.tsx | 60 +++++++--- .../bookEdit/css/origamiEditing.less | 63 +++++----- .../bookEdit/js/AbovePageControls.tsx | 47 ++++---- .../color-picking/colorPickerDialog.tsx | 112 +++++++++--------- 4 files changed, 156 insertions(+), 126 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx index 7fa32c98a3a6..4bd97f3a873c 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx @@ -82,49 +82,73 @@ const normalizeToHexOrTransparentOrEmpty = (color: string): string => { return parsed.toHexString().toUpperCase(); }; -const getComputedStyleForPage = (page: HTMLElement): CSSStyleDeclaration => { - const view = page.ownerDocument.defaultView; +const getComputedStyleForElement = ( + element: HTMLElement, +): CSSStyleDeclaration => { + const view = element.ownerDocument.defaultView; if (view) { - return view.getComputedStyle(page); + return view.getComputedStyle(element); } - return getComputedStyle(page); + return getComputedStyle(element); }; const getCurrentPageBackgroundColor = (): string => { const page = getCurrentPageElement(); - const computedPage = getComputedStyleForPage(page); + const computedPageStyle = getComputedStyleForElement(page); + // We cannot just read computedPageStyle.backgroundColor. In separated themes, + // the outer .bloom-page shell stays theme-colored while the user-facing page + // surface inside .marginBox has its own color, and the settings UI needs to + // round-trip the persisted page-surface color rather than the shell color. const inlineMarginBox = normalizeToHexOrEmpty( page.style.getPropertyValue("--marginBox-background-color"), ); + // Prefer an inline marginBox override first because this is the most direct + // persisted value for the editable page surface, and applyPageSettings() + // always writes it when the user picks a page color. if (inlineMarginBox) return inlineMarginBox; const inline = normalizeToHexOrEmpty( page.style.getPropertyValue("--page-background-color"), ); + // Next honor an inline page-shell override. Unified themes use this for the + // same visible surface, and persisted page styles from the DOM can specify + // the page shell even when there is no separate inline marginBox value. if (inline) return inline; const computedMarginBoxVariable = normalizeToHexOrEmpty( - computedPage.getPropertyValue("--marginBox-background-color"), + computedPageStyle.getPropertyValue("--marginBox-background-color"), ); + // If there is no inline override, the active theme may still define the + // visible page surface through the marginBox variable. This is the key case + // where a single computed page background would be wrong for rounded themes. if (computedMarginBoxVariable) return computedMarginBoxVariable; const computedVariable = normalizeToHexOrEmpty( - computedPage.getPropertyValue("--page-background-color"), + computedPageStyle.getPropertyValue("--page-background-color"), ); + // For flat themes, the themed default often lives on the page-background + // variable. Reading the variable preserves the intended setting without + // depending on which element ultimately paints the background. if (computedVariable) return computedVariable; const marginBox = page.querySelector(".marginBox") as HTMLElement | null; if (marginBox) { const computedMarginBoxBackground = normalizeToHexOrEmpty( - getComputedStyleForPage(marginBox).backgroundColor, + getComputedStyleForElement(marginBox).backgroundColor, ); + // Last resort: some CSS can paint the marginBox directly. If that + // happens without a useful custom property on .bloom-page, read the + // rendered marginBox background because that is still the visible page + // surface the dialog is editing. if (computedMarginBoxBackground) return computedMarginBoxBackground; } const computedBackground = normalizeToHexOrEmpty( - computedPage.backgroundColor, + computedPageStyle.backgroundColor, ); + // Final fallback for pages that do not expose a background through the + // custom properties or a distinct marginBox surface. return computedBackground || "#FFFFFF"; }; @@ -132,6 +156,11 @@ const doesThemeUseUnifiedPageBackground = ( page: HTMLElement, targetThemeName?: string, ): boolean => { + // "Unified page background" means the outer .bloom-page shell and the + // inner .marginBox are intended to look like one continuous surface, so a + // user-picked page color should be applied to both. The default theme works + // this way. Rounded themes are the opposite: they intentionally keep a + // distinct outer shell color around a differently colored marginBox. if (targetThemeName) { if (kSeparatedBackgroundThemes.has(targetThemeName)) { return false; @@ -140,9 +169,9 @@ const doesThemeUseUnifiedPageBackground = ( return true; } - const computedPage = getComputedStyleForPage(page); + const computedPageStyle = getComputedStyleForElement(page); const multiplicand = Number.parseFloat( - computedPage + computedPageStyle .getPropertyValue( "--page-and-marginBox-are-same-color-multiplicand", ) @@ -190,6 +219,9 @@ const setCurrentPageBackgroundColor = ( color, ); + // For unified (borderless) backgrounds we color both surfaces so the whole page stays + // flat. For separated backgrounds, such as rounded-border-ebook, we leave + // the outer page shell on the theme color and only recolor the marginBox. if (doesThemeUseUnifiedPageBackground(page, targetThemeName)) { setOrRemoveCustomProperty(page.style, "--page-background-color", color); } else { @@ -206,7 +238,7 @@ const getPageNumberColor = (): string => { if (inline) return inline; const computed = normalizeToHexOrEmpty( - getComputedStyleForPage(page).getPropertyValue("--pageNumber-color"), + getComputedStyleForElement(page).getPropertyValue("--pageNumber-color"), ); return computed || "#000000"; }; @@ -225,7 +257,7 @@ const getPageNumberOutlineColor = (): string => { if (inline) return inline; const computed = normalizeToHexOrTransparentOrEmpty( - getComputedStyleForPage(page).getPropertyValue( + getComputedStyleForElement(page).getPropertyValue( "--pageNumber-outline-color", ), ); @@ -250,7 +282,7 @@ const getPageNumberBackgroundColor = (): string => { if (inline) return inline; const computed = normalizeToHexOrTransparentOrEmpty( - getComputedStyleForPage(page).getPropertyValue( + getComputedStyleForElement(page).getPropertyValue( "--pageNumber-background-color", ), ); diff --git a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less index 79c819e5c154..6c715ce734cf 100644 --- a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less +++ b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less @@ -197,40 +197,41 @@ display: inline; } } -.page-settings-button { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 6px; - width: auto; - height: 24px; - padding: 0 4px; - margin-right: 8px; - border: none; - background-color: transparent; - cursor: pointer; - color: @bloom-purple; - white-space: nowrap; - font-family: inherit; - font-size: inherit; - line-height: inherit; - font-weight: inherit; - &:hover { - opacity: 0.8; - } +// .page-settings-button { +// display: flex; +// align-items: center; +// justify-content: flex-start; +// gap: 6px; +// width: auto; +// height: 24px; +// padding: 0 4px; +// margin-right: 8px; +// border: none; +// background-color: transparent; +// cursor: pointer; +// color: @bloom-purple; +// white-space: nowrap; +// font-family: inherit; +// font-size: inherit; +// line-height: inherit; +// font-weight: inherit; - svg { - width: 20px; - height: 20px; - flex-shrink: 0; - align-self: center; - } +// &:hover { +// opacity: 0.8; +// } - .page-settings-button-label { - white-space: nowrap; - } -} +// svg { +// width: 20px; +// height: 20px; +// flex-shrink: 0; +// align-self: center; +// } + +// .page-settings-button-label { +// white-space: nowrap; +// } +// } // here follows the inner workings of the toggle .onoffswitch { diff --git a/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx index c9025971eeb3..dd0f7228bd33 100644 --- a/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx @@ -204,27 +204,27 @@ const AbovePageControls: React.FunctionComponent = ( ); }; -const PageSettingsButton: React.FunctionComponent = () => { - const label = useL10n("Page Settings", "PageSettings.Title"); - const title = useL10n("Open Page Settings...", "PageSettings.OpenTooltip"); +// const PageSettingsButton: React.FunctionComponent = () => { +// const label = useL10n("Page Settings", "PageSettings.Title"); +// const title = useL10n("Open Page Settings...", "PageSettings.OpenTooltip"); - return ( - - ); -}; +// return ( +// +// ); +// }; const ChangeLayoutModeToggle: React.FunctionComponent<{ isChecked: boolean; @@ -240,11 +240,14 @@ const ChangeLayoutModeToggle: React.FunctionComponent<{ type="checkbox" name="onoffswitch" className="onoffswitch-checkbox" - id="myonoffswitch" + id="changeLayoutToggle" checked={props.isChecked} onChange={() => props.onChange?.()} /> -