From 3981e9189ebbb5198b51e9b2461d60cb83b8f48b Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 30 Dec 2025 16:21:02 -0700 Subject: [PATCH 01/17] BL-15642 Intro Page Settings --- .../bookEdit/css/origamiEditing.less | 25 +- src/BloomBrowserUI/bookEdit/js/origami.ts | 28 +- .../pageSettings/PageSettingsDialog.tsx | 392 ++++++++++++++++++ src/BloomBrowserUI/bookEdit/workspaceRoot.ts | 10 + src/BloomExe/Book/HtmlDom.cs | 8 + src/BloomExe/Edit/EditingView.cs | 24 ++ .../web/controllers/EditingViewApi.cs | 11 + .../efl-zeromargin1/customBookStyles.css | 2 +- .../appearance-theme-default.css | 2 + .../appearance-theme-rounded-border-ebook.css | 3 +- .../appearance-theme-zero-margin-ebook.css | 3 +- src/content/bookLayout/pageNumbers.less | 10 + yarn.lock | 4 + 13 files changed, 513 insertions(+), 9 deletions(-) create mode 100644 src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx create mode 100644 yarn.lock diff --git a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less index 308d3e85bd86..8d6be670b32a 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,28 @@ display: inline; } } +.page-settings-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + margin-right: 8px; + border: none; + background-color: transparent; + cursor: pointer; + color: @bloom-purple; + + &:hover { + opacity: 0.8; + } + + svg { + width: 20px; + height: 20px; + } +} // here follows the inner workings of the toggle .onoffswitch { diff --git a/src/BloomBrowserUI/bookEdit/js/origami.ts b/src/BloomBrowserUI/bookEdit/js/origami.ts index 6da140e5121f..eedeff6849bc 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"; @@ -51,6 +49,7 @@ export function setupOrigami() { // 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(); @@ -348,11 +347,16 @@ function getAbovePageControlContainer(): JQuery { .getElementsByClassName("bloom-page")[0] ?.getAttribute("data-tool-id") === "game" ) { - return $("
"); + return $( + `
\ +${getPageSettingsButtonHtml()}\ +
`, + ); } return $( - "\ + `\
\ +${getPageSettingsButtonHtml()}\
\
Change Layout
\
\ @@ -363,10 +367,24 @@ function getAbovePageControlContainer(): JQuery { \
\
\ -
", +`, ); } +function getPageSettingsButtonHtml(): string { + // SVG path matches MUI Settings icon + return ``; +} + +function pageSettingsButtonClickHandler(e: Event) { + e.preventDefault(); + post("editView/showPageSettingsDialog"); +} + function getButtons() { const buttons = $( "
", diff --git a/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx new file mode 100644 index 000000000000..ce4984eced25 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx @@ -0,0 +1,392 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { + ConfigrCustomStringInput, + ConfigrGroup, + ConfigrPane, + ConfigrSubgroup, +} from "@sillsdev/config-r"; +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 { useL10n } from "../../react_components/l10nHooks"; +import { + ColorDisplayButton, + DialogResult, +} from "../../react_components/color-picking/colorPickerDialog"; +import { BloomPalette } from "../../react_components/color-picking/bloomPalette"; +import { getPageIframeBody } from "../../utils/shared"; +import { ShowEditViewDialog } from "../editViewFrame"; +import tinycolor from "tinycolor2"; + +let isOpenAlready = false; + +type IPageSettings = { + page: { + backgroundColor: string; + pageNumberColor: string; + pageNumberBackgroundColor: string; + }; +}; + +const getCurrentPageElement = (): HTMLElement => { + const page = getPageIframeBody()?.querySelector( + ".bloom-page", + ) as HTMLElement | null; + if (!page) { + throw new Error( + "PageSettingsDialog could not find .bloom-page in the page iframe", + ); + } + return page; +}; + +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 ""; + } + + 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 inline = normalizeToHexOrEmpty( + page.style.getPropertyValue("--page-background-color"), + ); + if (inline) return inline; + + const computedVariable = normalizeToHexOrEmpty( + getComputedStyleForPage(page).getPropertyValue( + "--page-background-color", + ), + ); + if (computedVariable) return computedVariable; + + const computedMarginBoxVariable = normalizeToHexOrEmpty( + getComputedStyleForPage(page).getPropertyValue( + "--marginBox-background-color", + ), + ); + if (computedMarginBoxVariable) return computedMarginBoxVariable; + + const computedBackground = normalizeToHexOrEmpty( + getComputedStyleForPage(page).backgroundColor, + ); + return computedBackground || "#FFFFFF"; +}; + +const setCurrentPageBackgroundColor = (color: string): void => { + const page = getCurrentPageElement(); + page.style.setProperty("--page-background-color", color); + page.style.setProperty("--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(); + page.style.setProperty("--pageNumber-color", color); +}; + +const getPageNumberBackgroundColor = (): string => { + const page = getCurrentPageElement(); + + const inline = normalizeToHexOrEmpty( + page.style.getPropertyValue("--pageNumber-background-color"), + ); + if (inline) return inline; + + const computed = normalizeToHexOrEmpty( + getComputedStyleForPage(page).getPropertyValue( + "--pageNumber-background-color", + ), + ); + return computed || ""; +}; + +const setPageNumberBackgroundColor = (color: string): void => { + const page = getCurrentPageElement(); + page.style.setProperty("--pageNumber-background-color", color); +}; + +const PageBackgroundColorPickerForConfigr: React.FunctionComponent<{ + value: string; + disabled: boolean; + onChange: (value: string) => void; +}> = (props) => { + const backgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + /> + ); +}; + +const PageNumberColorPickerForConfigr: React.FunctionComponent<{ + value: string; + disabled: boolean; + onChange: (value: string) => void; +}> = (props) => { + const pageNumberColorLabel = useL10n( + "Page Number Color", + "PageSettings.PageNumberColor", + ); + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + /> + ); +}; + +const PageNumberBackgroundColorPickerForConfigr: React.FunctionComponent<{ + value: string; + disabled: boolean; + onChange: (value: string) => void; +}> = (props) => { + const pageNumberBackgroundColorLabel = useL10n( + "Page Number Background Color", + "PageSettings.PageNumberBackgroundColor", + ); + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + /> + ); +}; + +export const PageSettingsDialog: React.FunctionComponent = () => { + const { closeDialog, propsForBloomDialog } = useSetupBloomDialog({ + initiallyOpen: true, + dialogFrameProvidedExternally: false, + }); + + const pageSettingsTitle = useL10n("Page Settings", "PageSettings.Title"); + const backgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + const pageNumberColorLabel = useL10n( + "Page Number Color", + "PageSettings.PageNumberColor", + ); + const pageNumberBackgroundColorLabel = useL10n( + "Page Number Background Color", + "PageSettings.PageNumberBackgroundColor", + ); + + const [initialValues, setInitialValues] = React.useState< + IPageSettings | undefined + >(undefined); + + const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< + IPageSettings | string | undefined + >(undefined); + + // Read after mount so we get the current page's color even if opening this dialog + // is preceded by a save/refresh that updates the page iframe. + React.useEffect(() => { + setInitialValues({ + page: { + backgroundColor: getCurrentPageBackgroundColor(), + pageNumberColor: getPageNumberColor(), + pageNumberBackgroundColor: getPageNumberBackgroundColor(), + }, + }); + }, []); + + const onOk = (): void => { + const rawSettings = settingsToReturnLater ?? initialValues; + if (!rawSettings) { + throw new Error( + "PageSettingsDialog: expected settings to be loaded before OK", + ); + } + + const settings = + typeof rawSettings === "string" + ? (JSON.parse(rawSettings) as IPageSettings) + : rawSettings; + + setCurrentPageBackgroundColor(settings.page.backgroundColor); + setPageNumberColor(settings.page.pageNumberColor); + setPageNumberBackgroundColor(settings.page.pageNumberBackgroundColor); + isOpenAlready = false; + closeDialog(); + }; + + const onCancel = (): void => { + isOpenAlready = false; + closeDialog(); + }; + + return ( + + + + {initialValues && ( +
+ { + if (typeof s === "string") { + setSettingsToReturnLater(s); + return; + } + + if (typeof s === "object" && s) { + setSettingsToReturnLater( + s as IPageSettings, + ); + return; + } + + throw new Error( + "PageSettingsDialog: unexpected value from config-r onChange", + ); + }} + > + + + + + + + + +
+ )} +
+ + + + +
+ ); +}; + +export const showPageSettingsDialog = () => { + if (!isOpenAlready) { + isOpenAlready = true; + ShowEditViewDialog(); + } +}; diff --git a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts index fb38193e98fe..277447881a06 100644 --- a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts +++ b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts @@ -61,6 +61,7 @@ export { showPageChooserDialog }; import "../lib/errorHandler"; import { showBookSettingsDialog } from "./bookSettings/BookSettingsDialog"; export { showBookSettingsDialog }; +import { showPageSettingsDialog } from "./pageSettings/PageSettingsDialog"; import { showRegistrationDialogForEditTab } from "../react_components/registration/registrationDialog"; export { showRegistrationDialogForEditTab as showRegistrationDialog }; import { showAboutDialog } from "../react_components/aboutDialog"; @@ -275,6 +276,11 @@ export function showEditViewBookSettingsDialog( } export function showAboutDialogFromWorkspaceRoot() { +export function showEditViewPageSettingsDialog() { + showPageSettingsDialog(); +} + +export function showAboutDialogInEditTab() { showAboutDialog(); } @@ -434,6 +440,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 +481,8 @@ window.workspaceBundle = { showEditViewTopicChooserDialog, showEditViewBookSettingsDialog, showAboutDialogFromWorkspaceRoot, + showEditViewPageSettingsDialog, + showAboutDialogInEditTab, showRequiresSubscriptionDialog, showRegistrationDialogFromWorkspaceRoot, setWorkspaceMode, diff --git a/src/BloomExe/Book/HtmlDom.cs b/src/BloomExe/Book/HtmlDom.cs index b2161906ad24..1bc941267c8d 100644 --- a/src/BloomExe/Book/HtmlDom.cs +++ b/src/BloomExe/Book/HtmlDom.cs @@ -1921,6 +1921,14 @@ SafeXmlElement edittedPageDiv //html file in a browser. destinationPageDiv.SetAttribute("lang", edittedPageDiv.GetAttribute("lang")); + // Allow saving per-page CSS custom properties (e.g. --page-background-color) stored on the page div. + // If missing, remove any previously-saved style. + var style = edittedPageDiv.GetAttribute("style"); + 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..0caed6004c8e 100644 --- a/src/BloomExe/Edit/EditingView.cs +++ b/src/BloomExe/Edit/EditingView.cs @@ -1723,6 +1723,30 @@ public void SaveAndOpenBookSettingsDialog() ); } + public void SaveAndOpenPageSettingsDialog() + { + _model.SaveThen( + () => + { + RunJavascriptAsync("editTabBundle.showEditViewPageSettingsDialog();"); + return _model.CurrentPage.Id; + }, + () => { } // wrong state, do nothing + ); + } + + private void _pageSettingsButton_Click(object sender, EventArgs e) + { + SaveAndOpenPageSettingsDialog(); + } + + // This is temporary code we added in 6.0 when trying to determine why we are sometimes losing + // user data upon save. See BL-13120. + private void _topBarPanel_Click(object sender, EventArgs e) + { + if (Model.Visible && ModifierKeys == (Keys.Shift | Keys.Control)) + _model.RethinkPageAndReloadItAndReportIfItFails(); + } 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/content/appearanceMigrations/efl-zeromargin1/customBookStyles.css b/src/content/appearanceMigrations/efl-zeromargin1/customBookStyles.css index c70b168314f5..6a7f94f5558a 100644 --- a/src/content/appearanceMigrations/efl-zeromargin1/customBookStyles.css +++ b/src/content/appearanceMigrations/efl-zeromargin1/customBookStyles.css @@ -28,7 +28,7 @@ --pageNumber-color: black; --pageNumber-background-width: 17px; --pageNumber-border-radius: 50%; - --pageNumber-background-color: #ffffff; + --pageNumber-background-color: transparent; font-family: "ABeeZee"; z-index: 1000; diff --git a/src/content/appearanceThemes/appearance-theme-default.css b/src/content/appearanceThemes/appearance-theme-default.css index 532dbed270cb..f7d7274e5a36 100644 --- a/src/content/appearanceThemes/appearance-theme-default.css +++ b/src/content/appearanceThemes/appearance-theme-default.css @@ -39,6 +39,8 @@ --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; /* 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..8cf4c0d7626f 100644 --- a/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css +++ b/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css @@ -29,13 +29,14 @@ [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..06115f6b9be3 100644 --- a/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css +++ b/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css @@ -25,6 +25,7 @@ Note that hiding the page numbers is done by a setting in appearance.json, not h /* this rule will apply more generally than we'd prefer, but the common situation we're improving here is picture on top, text on the bottom, we need to make room for the page number */ --pageNumber-extra-height: 12mm !important; + --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 */ } .bloom-fullBleed .Device16x9Landscape.numberedPage { /* On full-bleed pages we want the page number positioned based on the ideal place where @@ -41,7 +42,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..9a7a871becd4 100644 --- a/src/content/bookLayout/pageNumbers.less +++ b/src/content/bookLayout/pageNumbers.less @@ -7,6 +7,14 @@ // 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 } + +// If the page has explicitly set a background color (e.g. via Page Settings), +// make the page number background match it, unless it has its own explicit setting. +// .bloom-page.numberedPage[style*="--page-background-color"]:not( +// [style*="--pageNumber-background-color"] +// ) { +// --pageNumber-background-color: var(--page-background-color); +// } .numberedPage { &:after { content: attr(data-page-number); @@ -22,6 +30,8 @@ bottom: var(--pageNumber-bottom); top: var(--pageNumber-top); background-color: var(--pageNumber-background-color); + color: var(--pageNumber-color); + 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 5f913e35344791700508696eb2c19914f1f18cad Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 30 Dec 2025 16:44:01 -0700 Subject: [PATCH 02/17] See color results on page as you make changes. --- .../pageSettings/PageSettingsDialog.tsx | 25 ++++++++--- .../color-picking/colorPickerDialog.tsx | 45 ++++++++++++++++--- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx index ce4984eced25..b5c6d92e6d87 100644 --- a/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx @@ -151,6 +151,12 @@ const setPageNumberBackgroundColor = (color: string): void => { page.style.setProperty("--pageNumber-background-color", color); }; +const applyPageSettings = (settings: IPageSettings): void => { + setCurrentPageBackgroundColor(settings.page.backgroundColor); + setPageNumberColor(settings.page.pageNumberColor); + setPageNumberBackgroundColor(settings.page.pageNumberBackgroundColor); +}; + const PageBackgroundColorPickerForConfigr: React.FunctionComponent<{ value: string; disabled: boolean; @@ -172,6 +178,7 @@ const PageBackgroundColorPickerForConfigr: React.FunctionComponent<{ onClose={(dialogResult: DialogResult, newColor: string) => { if (dialogResult === DialogResult.OK) props.onChange(newColor); }} + onChange={(newColor) => props.onChange(newColor)} /> ); }; @@ -197,6 +204,7 @@ const PageNumberColorPickerForConfigr: React.FunctionComponent<{ onClose={(dialogResult: DialogResult, newColor: string) => { if (dialogResult === DialogResult.OK) props.onChange(newColor); }} + onChange={(newColor) => props.onChange(newColor)} /> ); }; @@ -222,6 +230,7 @@ const PageNumberBackgroundColorPickerForConfigr: React.FunctionComponent<{ onClose={(dialogResult: DialogResult, newColor: string) => { if (dialogResult === DialogResult.OK) props.onChange(newColor); }} + onChange={(newColor) => props.onChange(newColor)} /> ); }; @@ -279,14 +288,15 @@ export const PageSettingsDialog: React.FunctionComponent = () => { ? (JSON.parse(rawSettings) as IPageSettings) : rawSettings; - setCurrentPageBackgroundColor(settings.page.backgroundColor); - setPageNumberColor(settings.page.pageNumberColor); - setPageNumberBackgroundColor(settings.page.pageNumberBackgroundColor); + applyPageSettings(settings); isOpenAlready = false; closeDialog(); }; const onCancel = (): void => { + if (initialValues) { + applyPageSettings(initialValues); + } isOpenAlready = false; closeDialog(); }; @@ -329,13 +339,16 @@ export const PageSettingsDialog: React.FunctionComponent = () => { onChange={(s: unknown) => { if (typeof s === "string") { setSettingsToReturnLater(s); + applyPageSettings( + JSON.parse(s) as IPageSettings, + ); return; } if (typeof s === "object" && s) { - setSettingsToReturnLater( - s as IPageSettings, - ); + const settings = s as IPageSettings; + setSettingsToReturnLater(settings); + applyPageSettings(settings); return; } diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx index 14f6181972cc..987c73f18476 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx @@ -1,4 +1,4 @@ -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"; @@ -283,9 +283,30 @@ const ColorPickerDialog: React.FC = (props) => { props.onChange(color); }; + const dialogOpen = props.open === undefined ? open : props.open; + + // The MUI backdrop is rendered outside the dialog tree, so we use a body class + // to suppress it while the color picker is open. + 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} + hideBackdrop={true} + BackdropProps={{ + invisible: true, + }} + slotProps={{ + backdrop: { + invisible: true, + }, + }} + open={dialogOpen} ref={dlgRef} onClose={( _event, @@ -429,6 +459,7 @@ export interface IColorDisplayButtonProps { width?: number; disabled?: boolean; onClose: (result: DialogResult, newColor: string) => void; + onChange?: (newColor: string) => void; palette: BloomPalette; } @@ -494,9 +525,13 @@ export const ColorDisplayButton: React.FC = ( props.initialColor, )} onInputFocus={() => {}} - onChange={(color: IColorInfo) => - setCurrentButtonColor(color.colors[0]) - } + onChange={(color: IColorInfo) => { + const newColor = color.colors[0]; + setCurrentButtonColor(newColor); + if (props.onChange) { + props.onChange(newColor); + } + }} /> ); From 67611c827d7194086fbb3e1f06826fb066aefc97 Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 30 Dec 2025 16:48:38 -0700 Subject: [PATCH 03/17] Add palette for page backgrounds --- .../pageSettings/PageSettingsDialog.tsx | 6 ++--- .../color-picking/bloomPalette.ts | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx index b5c6d92e6d87..47ef932a86dd 100644 --- a/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx @@ -173,7 +173,7 @@ const PageBackgroundColorPickerForConfigr: React.FunctionComponent<{ initialColor={props.value} localizedTitle={backgroundColorLabel} transparency={false} - palette={BloomPalette.CoverBackground} + palette={BloomPalette.PageColors} width={75} onClose={(dialogResult: DialogResult, newColor: string) => { if (dialogResult === DialogResult.OK) props.onChange(newColor); @@ -199,7 +199,7 @@ const PageNumberColorPickerForConfigr: React.FunctionComponent<{ initialColor={props.value} localizedTitle={pageNumberColorLabel} transparency={false} - palette={BloomPalette.CoverBackground} + palette={BloomPalette.Text} width={75} onClose={(dialogResult: DialogResult, newColor: string) => { if (dialogResult === DialogResult.OK) props.onChange(newColor); @@ -225,7 +225,7 @@ const PageNumberBackgroundColorPickerForConfigr: React.FunctionComponent<{ initialColor={props.value || "transparent"} localizedTitle={pageNumberBackgroundColorLabel} transparency={true} - palette={BloomPalette.CoverBackground} + palette={BloomPalette.PageColors} width={75} onClose={(dialogResult: DialogResult, newColor: string) => { if (dialogResult === DialogResult.OK) props.onChange(newColor); diff --git a/src/BloomBrowserUI/react_components/color-picking/bloomPalette.ts b/src/BloomBrowserUI/react_components/color-picking/bloomPalette.ts index 2dcc70366fe7..b9ac2d951248 100644 --- a/src/BloomBrowserUI/react_components/color-picking/bloomPalette.ts +++ b/src/BloomBrowserUI/react_components/color-picking/bloomPalette.ts @@ -8,6 +8,7 @@ export enum BloomPalette { BloomReaderBookshelf = "bloom-reader-bookshelf", TextBackground = "overlay-background", HighlightBackground = "highlight-background", + PageColors = "page-colors", } // This array provides a useful default palette for the color picker dialog. @@ -64,6 +65,25 @@ export const HighlightBackgroundPalette: string[] = [ "#C5F0FF", ]; +// Light background colors suitable for page backgrounds. +// (Users can still pick any color, but these are the suggested defaults.) +export const PageColorsPalette: string[] = [ + "#FFFFFF", // white + "#F7F7F7", // very light gray + "#FFF7E6", // warm cream + "#FFF1F2", // very light pink + "#FCE7F3", // pale rose + "#F3E8FF", // pale lavender + "#EDE9FE", // pale purple + "#E0F2FE", // pale sky + "#E0F7FA", // pale cyan + "#E6FFFA", // pale teal + "#ECFDF3", // pale green + "#F7FEE7", // pale lime + "#FFFBEB", // pale amber + "#FEF3C7", // light beige +]; + const specialColors: IColorInfo[] = [ // #DFB28B is the color Comical has been using as the default for captions. // It's fairly close to the "Calico" color defined at https://www.htmlcsscolor.com/hex/D5B185 (#D5B185) @@ -110,6 +130,9 @@ export async function getHexColorsForPalette( case BloomPalette.CoverBackground: factoryColors = CoverBackgroundPalette; break; + case BloomPalette.PageColors: + factoryColors = PageColorsPalette; + break; case BloomPalette.Text: factoryColors = TextColorPalette; break; @@ -156,6 +179,9 @@ export function getDefaultColorsFromPalette( case BloomPalette.CoverBackground: palette = CoverBackgroundPalette; break; + case BloomPalette.PageColors: + palette = PageColorsPalette; + break; case BloomPalette.Text: palette = TextColorPalette; break; From 0cb90010945a066b39c1f1617e85f3526cd9842b Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 30 Dec 2025 17:01:17 -0700 Subject: [PATCH 04/17] review fixes --- .../pageSettings/PageSettingsDialog.tsx | 57 +++++++++++++++---- .../appearance-theme-rounded-border-ebook.css | 1 - .../appearance-theme-zero-margin-ebook.css | 5 +- src/content/bookLayout/pageNumbers.less | 7 --- 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx index 47ef932a86dd..4ddce0a35e03 100644 --- a/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx @@ -105,10 +105,27 @@ const getCurrentPageBackgroundColor = (): string => { 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 setCurrentPageBackgroundColor = (color: string): void => { const page = getCurrentPageElement(); - page.style.setProperty("--page-background-color", color); - page.style.setProperty("--marginBox-background-color", color); + setOrRemoveCustomProperty(page.style, "--page-background-color", color); + setOrRemoveCustomProperty( + page.style, + "--marginBox-background-color", + color, + ); }; const getPageNumberColor = (): string => { @@ -127,7 +144,7 @@ const getPageNumberColor = (): string => { const setPageNumberColor = (color: string): void => { const page = getCurrentPageElement(); - page.style.setProperty("--pageNumber-color", color); + setOrRemoveCustomProperty(page.style, "--pageNumber-color", color); }; const getPageNumberBackgroundColor = (): string => { @@ -148,7 +165,11 @@ const getPageNumberBackgroundColor = (): string => { const setPageNumberBackgroundColor = (color: string): void => { const page = getCurrentPageElement(); - page.style.setProperty("--pageNumber-background-color", color); + setOrRemoveCustomProperty( + page.style, + "--pageNumber-background-color", + color, + ); }; const applyPageSettings = (settings: IPageSettings): void => { @@ -241,6 +262,11 @@ export const PageSettingsDialog: React.FunctionComponent = () => { dialogFrameProvidedExternally: false, }); + const closeDialogAndClearOpenFlag = React.useCallback(() => { + isOpenAlready = false; + closeDialog(); + }, [closeDialog]); + const pageSettingsTitle = useL10n("Page Settings", "PageSettings.Title"); const backgroundColorLabel = useL10n( "Background Color", @@ -289,22 +315,33 @@ export const PageSettingsDialog: React.FunctionComponent = () => { : rawSettings; applyPageSettings(settings); - isOpenAlready = false; - closeDialog(); + closeDialogAndClearOpenFlag(); }; - const onCancel = (): void => { + const onCancel = ( + _reason?: + | "escapeKeyDown" + | "backdropClick" + | "titleCloseClick" + | "cancelClicked", + ): void => { if (initialValues) { applyPageSettings(initialValues); } - isOpenAlready = false; - closeDialog(); + closeDialogAndClearOpenFlag(); + }; + + const onClose = ( + _evt?: object, + _reason?: "escapeKeyDown" | "backdropClick", + ): void => { + onCancel(_reason); }; return ( Date: Thu, 5 Mar 2026 07:46:44 -0700 Subject: [PATCH 05/17] Update configr, work on cancel --- .../pageSettings/PageSettingsDialog.tsx | 114 +++++++++++++----- src/BloomBrowserUI/package.json | 2 +- .../utils/ElementAttributeSnapshot.ts | 50 ++++++++ src/BloomBrowserUI/yarn.lock | 8 +- src/BloomExe/Edit/EditingView.cs | 1 + 5 files changed, 140 insertions(+), 35 deletions(-) create mode 100644 src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts diff --git a/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx index 4ddce0a35e03..02e8ce4b3ad4 100644 --- a/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx @@ -3,8 +3,8 @@ import * as React from "react"; import { ConfigrCustomStringInput, ConfigrGroup, + ConfigrPage, ConfigrPane, - ConfigrSubgroup, } from "@sillsdev/config-r"; import { kBloomBlue } from "../../bloomMaterialUITheme"; import { @@ -24,6 +24,7 @@ import { DialogResult, } from "../../react_components/color-picking/colorPickerDialog"; import { BloomPalette } from "../../react_components/color-picking/bloomPalette"; +import { ElementAttributeSnapshot } from "../../utils/ElementAttributeSnapshot"; import { getPageIframeBody } from "../../utils/shared"; import { ShowEditViewDialog } from "../editViewFrame"; import tinycolor from "tinycolor2"; @@ -79,32 +80,70 @@ const getComputedStyleForPage = (page: HTMLElement): CSSStyleDeclaration => { 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( - getComputedStyleForPage(page).getPropertyValue( - "--page-background-color", - ), + computedPage.getPropertyValue("--page-background-color"), ); if (computedVariable) return computedVariable; - const computedMarginBoxVariable = normalizeToHexOrEmpty( - getComputedStyleForPage(page).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; + } const computedBackground = normalizeToHexOrEmpty( - getComputedStyleForPage(page).backgroundColor, + computedPage.backgroundColor, ); return computedBackground || "#FFFFFF"; }; +const parsePageSettingsFromConfigrValue = (value: unknown): IPageSettings => { + if (typeof value === "string") { + return JSON.parse(value) as IPageSettings; + } + + if (typeof value === "object" && value) { + return value as IPageSettings; + } + + throw new Error( + "PageSettingsDialog: unexpected value from config-r onChange", + ); +}; + +const arePageSettingsEquivalent = ( + first: IPageSettings, + second: IPageSettings, +): boolean => { + return ( + normalizeToHexOrEmpty(first.page.backgroundColor) === + normalizeToHexOrEmpty(second.page.backgroundColor) && + normalizeToHexOrEmpty(first.page.pageNumberColor) === + normalizeToHexOrEmpty(second.page.pageNumberColor) && + normalizeToHexOrEmpty(first.page.pageNumberBackgroundColor) === + normalizeToHexOrEmpty(second.page.pageNumberBackgroundColor) + ); +}; + const setOrRemoveCustomProperty = ( style: CSSStyleDeclaration, propertyName: string, @@ -289,9 +328,16 @@ export const PageSettingsDialog: React.FunctionComponent = () => { IPageSettings | string | undefined >(undefined); + const initialPageAttributeSnapshot = React.useRef< + ElementAttributeSnapshot | undefined + >(undefined); + // Read after mount so we get the current page's color even if opening this dialog // is preceded by a save/refresh that updates the page iframe. React.useEffect(() => { + initialPageAttributeSnapshot.current = + ElementAttributeSnapshot.fromElement(getCurrentPageElement()); + setInitialValues({ page: { backgroundColor: getCurrentPageBackgroundColor(), @@ -325,8 +371,10 @@ export const PageSettingsDialog: React.FunctionComponent = () => { | "titleCloseClick" | "cancelClicked", ): void => { - if (initialValues) { - applyPageSettings(initialValues); + if (initialPageAttributeSnapshot.current) { + initialPageAttributeSnapshot.current.restoreToElement( + getCurrentPageElement(), + ); } closeDialogAndClearOpenFlag(); }; @@ -365,7 +413,6 @@ export const PageSettingsDialog: React.FunctionComponent = () => { { showAppBar={false} showJson={false} onChange={(s: unknown) => { - if (typeof s === "string") { - setSettingsToReturnLater(s); - applyPageSettings( - JSON.parse(s) as IPageSettings, - ); + const settings = + parsePageSettingsFromConfigrValue(s); + + if ( + !settingsToReturnLater && + initialValues && + arePageSettingsEquivalent( + settings, + initialValues, + ) + ) { return; } - if (typeof s === "object" && s) { - const settings = s as IPageSettings; + if (typeof s === "string") { + setSettingsToReturnLater(s); + } else { setSettingsToReturnLater(settings); - applyPageSettings(settings); - return; } - throw new Error( - "PageSettingsDialog: unexpected value from config-r onChange", - ); + applyPageSettings(settings); }} > - - + + { } disabled={false} /> - - + + )} diff --git a/src/BloomBrowserUI/package.json b/src/BloomBrowserUI/package.json index 642e44986557..d181025c93a1 100644 --- a/src/BloomBrowserUI/package.json +++ b/src/BloomBrowserUI/package.json @@ -126,7 +126,7 @@ "@nivo/core": "^0.80.0", "@nivo/scatterplot": "^0.80.0", "@nivo/tooltip": "^0.80.0", - "@sillsdev/config-r": "1.0.0-alpha.18", + "@sillsdev/config-r": "1.0.0-alpha.19", "@types/filesize": "^5.0.0", "@types/react-transition-group": "^4.4.1", "@use-it/event-listener": "^0.1.7", 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..02427ae70006 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.19": + version "1.0.0-alpha.19" + resolved "https://registry.npmjs.org/@sillsdev/config-r/-/config-r-1.0.0-alpha.19.tgz#012888b7309e87b970d9b423c616eed395315ff7" + integrity sha512-tKTaFS2MwJ7z1dmUWXGCej59g6e1lLXzpJq0LG/Yov+P0EdOB4m32wxS2QMscz17/TBUy2IFNh4UkjTmRjNMNg== dependencies: "@textea/json-viewer" "^2.13.1" formik "^2.2.9" diff --git a/src/BloomExe/Edit/EditingView.cs b/src/BloomExe/Edit/EditingView.cs index 0caed6004c8e..bd7408020098 100644 --- a/src/BloomExe/Edit/EditingView.cs +++ b/src/BloomExe/Edit/EditingView.cs @@ -1747,6 +1747,7 @@ private void _topBarPanel_Click(object sender, EventArgs e) if (Model.Visible && ModifierKeys == (Keys.Shift | Keys.Control)) _model.RethinkPageAndReloadItAndReportIfItFails(); } + public async Task AddImageFromUrlAsync(string desiredFileNameWithoutExtension, string url) { using (var client = new System.Net.Http.HttpClient()) From 5cb953fe468bfdf62bc6ee46094670c323f5e763 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 29 Dec 2025 10:10:18 -0700 Subject: [PATCH 06/17] Add eyedropper to color picker --- .../color-picking/colorPicker.tsx | 199 ++++++++++- .../color-picking/colorPickerDialog.tsx | 310 ++++++++++-------- .../colorDisplayButton.uitest.ts | 36 ++ .../colorDisplayButtonTestHarness.tsx | 19 ++ .../component-tests/colorPicker.uitest.ts | 63 ++++ .../colorPickerManualHarness.tsx | 41 +++ .../colorPickerTestHarness.tsx | 41 +++ .../component-tests/show-component.uitest.ts | 58 ++++ .../color-picking/hexColorInput.tsx | 60 ++-- .../react_components/color-picking/show.sh | 13 + .../react_components/color-picking/test.sh | 15 + 11 files changed, 688 insertions(+), 167 deletions(-) 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 diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx index f4d3a8f4926d..d855487b47fe 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. @@ -19,13 +22,126 @@ interface IColorPickerProps { 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(); + + // Use a content-based key so we detect when the color content changes, + // even if the object reference is the same (e.g., eyedropper mutations). + const currentColorKey = + props.currentColor.colors.join("|") + "|" + props.currentColor.opacity; + + // 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", @@ -33,8 +149,11 @@ export const ColorPicker: React.FunctionComponent = ( ); const changeColor = (swatchColor: IColorInfo) => { - setColorChoice(swatchColor); - props.onChange(swatchColor); + const clonedColor: IColorInfo = { + ...swatchColor, + colors: [...swatchColor.colors], + }; + props.onChange(clonedColor); }; // Handler for when the user clicks on a swatch at the bottom of the picker. @@ -52,7 +171,7 @@ export const ColorPicker: React.FunctionComponent = ( const handleHexCodeChange = (hexColor: string) => { const newColor = { colors: [hexColor], - opacity: colorChoice.opacity, // Don't change opacity + opacity: props.currentColor.opacity, // Don't change opacity }; changeColor(newColor); }; @@ -81,11 +200,44 @@ 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), + ); + } + } 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 @@ -122,29 +274,50 @@ export const ColorPicker: React.FunctionComponent = ( `} >
+ {hasNativeEyedropper && ( + + + + )} diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx index 987c73f18476..0d4a93417219 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, 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; @@ -51,6 +91,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 +106,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 +218,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 +276,7 @@ const ColorPickerDialog: React.FC = (props) => { input.removeEventListener("focus", focusFunc), ); }; - }, [dlgRef.current]); + }, []); const convertJsonColorArrayToColorInfos = ( jsonArray: IColorInfo[], @@ -173,114 +321,16 @@ 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; - }; - - // 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 handleOnChange = (color: IColorInfo) => { - setCurrentColor(color); - props.onChange(color); + const clonedColor: IColorInfo = { + ...color, + colors: [...color.colors], + }; + setCurrentColor(clonedColor); + props.onChange(clonedColor); }; const dialogOpen = props.open === undefined ? open : props.open; @@ -338,6 +388,9 @@ const ColorPickerDialog: React.FC = (props) => { _event, reason: "backdropClick" | "escapeKeyDown", ) => { + if (eyedropperActive) { + return; + } if (reason === "backdropClick") onClose(DialogResult.OK); if (reason === "escapeKeyDown") @@ -360,6 +413,7 @@ const ColorPickerDialog: React.FC = (props) => { noGradientSwatches={props.noGradientSwatches} includeDefault={props.includeDefault} onDefaultClick={props.onDefaultClick} + onEyedropperActiveChange={setEyedropperActive} //defaultColor={props.defaultColor} /> @@ -396,13 +450,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 = ( @@ -472,6 +520,11 @@ export const ColorDisplayButton: React.FC = ( ); const widthString = props.width ? `width: ${props.width}px;` : ""; + const initialColorInfo = React.useMemo( + () => getColorInfoFromSpecialNameOrColorString(props.initialColor), + [props.initialColor], + ); + useEffect(() => { if (currentButtonColor !== props.initialColor) { setCurrentButtonColor(props.initialColor); @@ -496,6 +549,7 @@ export const ColorDisplayButton: React.FC = ( `} >
= ( localizedTitle={props.localizedTitle} transparency={props.transparency} palette={props.palette} - initialColor={getColorInfoFromSpecialNameOrColorString( - props.initialColor, - )} + initialColor={initialColorInfo} onInputFocus={() => {}} onChange={(color: IColorInfo) => { const newColor = color.colors[0]; 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..4c51210bb6a6 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts @@ -0,0 +1,36 @@ +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"); + }); +}); 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..a2abdd7da088 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import { ColorDisplayButton, DialogResult } from "../colorPickerDialog"; +import { BloomPalette } from "../bloomPalette"; + +export const ColorDisplayButtonTestHarness: React.FunctionComponent = () => { + return ( +
+ {}} + /> +
+ ); +}; 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..aa92c5b338e1 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts @@ -0,0 +1,63 @@ +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("#111111"); + + await page.locator(".swatch-row .color-swatch").first().click(); + await expect(hexInput).toHaveValue("#AA0000"); + }); + + 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("#111111"); + + await page.locator('button[title="Sample Color"]').click(); + await expect(hexInput).toHaveValue("#00AA00"); + }); + + 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("#111111"); + + await page.getByTestId("simulate-external-color").click(); + await expect(hexInput).toHaveValue("#123456"); + }); +}); 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..df96b6db2510 100644 --- a/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx @@ -11,37 +11,47 @@ interface IHexColorInputProps { const hashChar = "#"; +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; +}; + +// 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): 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]; + 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 getInitialHexValue = (colorInfo: IColorInfo): string => { + return massageColorInput(getHexColorValueFromColorInfo(colorInfo)); +}; + export const HexColorInput: React.FunctionComponent = ( props, ) => { - const [currentColor, setCurrentColor] = useState(""); - - // 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(() => + getInitialHexValue(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 = getInitialHexValue(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, 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 From 86b22b60aa7fc162408b03f905d16e305125823a Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 5 Mar 2026 15:18:17 -0700 Subject: [PATCH 07/17] Add page settings to edit-tab settings dialog --- .../prompts/bloom-test-CURRENTPAGE.prompt.md | 4 + .../localization/am/BloomMediumPriority.xlf | 6 +- .../localization/ar/BloomMediumPriority.xlf | 6 +- .../localization/az/BloomMediumPriority.xlf | 6 +- .../localization/bn/BloomMediumPriority.xlf | 6 +- .../localization/en/BloomMediumPriority.xlf | 41 +- .../localization/es/BloomMediumPriority.xlf | 6 +- .../localization/fr/BloomMediumPriority.xlf | 6 +- .../localization/fuc/BloomMediumPriority.xlf | 6 +- .../localization/ha/BloomMediumPriority.xlf | 6 +- .../localization/hi/BloomMediumPriority.xlf | 6 +- .../localization/id/BloomMediumPriority.xlf | 6 +- .../localization/km/BloomMediumPriority.xlf | 6 +- .../localization/ksw/BloomMediumPriority.xlf | 6 +- .../localization/kw/BloomMediumPriority.xlf | 6 +- .../localization/ky/BloomMediumPriority.xlf | 6 +- .../localization/lo/BloomMediumPriority.xlf | 6 +- .../localization/mam/BloomMediumPriority.xlf | 6 +- .../localization/my/BloomMediumPriority.xlf | 6 +- .../localization/ne/BloomMediumPriority.xlf | 6 +- .../localization/pbu/BloomMediumPriority.xlf | 6 +- .../localization/prs/BloomMediumPriority.xlf | 6 +- .../localization/pt/BloomMediumPriority.xlf | 6 +- .../localization/qaa/BloomMediumPriority.xlf | 6 +- .../localization/quc/BloomMediumPriority.xlf | 6 +- .../localization/ru/BloomMediumPriority.xlf | 6 +- .../localization/rw/BloomMediumPriority.xlf | 6 +- .../localization/sw/BloomMediumPriority.xlf | 6 +- .../localization/ta/BloomMediumPriority.xlf | 6 +- .../localization/te/BloomMediumPriority.xlf | 6 +- .../localization/tg/BloomMediumPriority.xlf | 6 +- .../localization/th/BloomMediumPriority.xlf | 6 +- .../localization/tl/BloomMediumPriority.xlf | 6 +- .../localization/tr/BloomMediumPriority.xlf | 6 +- .../localization/uz/BloomMediumPriority.xlf | 6 +- .../localization/vi/BloomMediumPriority.xlf | 6 +- .../localization/yua/BloomMediumPriority.xlf | 6 +- .../zh-CN/BloomMediumPriority.xlf | 6 +- .../BookAndPageSettingsDialog.tsx | 481 ++++++++ .../BookSettingsConfigrPages.tsx | 774 ++++++++++++ .../FieldVisibilityGroup.tsx | 0 .../PageSettingsConfigrPages.tsx | 452 +++++++ .../StyleAndFontTable.tsx | 0 .../appearanceThemeUtils.ts | 0 .../bookSettings/BookSettingsDialog.tsx | 1098 ----------------- .../bookEdit/css/origamiEditing.less | 17 +- .../js/CanvasElementContextControls.tsx | 2 +- src/BloomBrowserUI/bookEdit/js/origami.ts | 44 +- .../pageSettings/PageSettingsDialog.tsx | 496 -------- .../toolbox/canvas/customXmatterPage.tsx | 4 +- src/BloomBrowserUI/bookEdit/workspaceRoot.ts | 6 +- .../collectionsTab/BookButton.tsx | 2 +- src/BloomBrowserUI/package.json | 2 +- .../react_components/BookInfoIndicator.tsx | 4 +- .../color-picking/colorPicker.tsx | 13 +- .../color-picking/colorPickerDialog.tsx | 48 +- .../color-picking/hexColorInput.tsx | 58 +- src/BloomBrowserUI/yarn.lock | 8 +- 58 files changed, 2015 insertions(+), 1755 deletions(-) create mode 100644 .github/prompts/bloom-test-CURRENTPAGE.prompt.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 (100%) 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 delete mode 100644 src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx 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/DistFiles/localization/am/BloomMediumPriority.xlf b/DistFiles/localization/am/BloomMediumPriority.xlf index 61221d938dd9..345cfe81ca8e 100644 --- a/DistFiles/localization/am/BloomMediumPriority.xlf +++ b/DistFiles/localization/am/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/ar/BloomMediumPriority.xlf b/DistFiles/localization/ar/BloomMediumPriority.xlf index 6675649c9a43..0794e6820229 100644 --- a/DistFiles/localization/ar/BloomMediumPriority.xlf +++ b/DistFiles/localization/ar/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/az/BloomMediumPriority.xlf b/DistFiles/localization/az/BloomMediumPriority.xlf index c960b1aee627..86c90bc2c2a3 100644 --- a/DistFiles/localization/az/BloomMediumPriority.xlf +++ b/DistFiles/localization/az/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/bn/BloomMediumPriority.xlf b/DistFiles/localization/bn/BloomMediumPriority.xlf index cf8d7a95ea08..5f8d567a4106 100644 --- a/DistFiles/localization/bn/BloomMediumPriority.xlf +++ b/DistFiles/localization/bn/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index 4d540aaf3a23..2a540cf4dd93 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -727,11 +727,46 @@ BookSettings.LockedByXMatter {0} is the name of an xmatter pack - - Book Settings - BookSettings.Title + + Book and Page Settings + 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/DistFiles/localization/es/BloomMediumPriority.xlf b/DistFiles/localization/es/BloomMediumPriority.xlf index 76900d85e875..0ff83064a00e 100644 --- a/DistFiles/localization/es/BloomMediumPriority.xlf +++ b/DistFiles/localization/es/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Bloqueado por {0} material de las páginas de inicio y final BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Configuración del libro - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/fr/BloomMediumPriority.xlf b/DistFiles/localization/fr/BloomMediumPriority.xlf index 4526c25aeffe..39f74fcb33f7 100644 --- a/DistFiles/localization/fr/BloomMediumPriority.xlf +++ b/DistFiles/localization/fr/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Paramètres du Livre - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/fuc/BloomMediumPriority.xlf b/DistFiles/localization/fuc/BloomMediumPriority.xlf index fa7b09ec92f3..c009620293cb 100644 --- a/DistFiles/localization/fuc/BloomMediumPriority.xlf +++ b/DistFiles/localization/fuc/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/ha/BloomMediumPriority.xlf b/DistFiles/localization/ha/BloomMediumPriority.xlf index 5199f9c282c9..ba7a7022b6cb 100644 --- a/DistFiles/localization/ha/BloomMediumPriority.xlf +++ b/DistFiles/localization/ha/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/hi/BloomMediumPriority.xlf b/DistFiles/localization/hi/BloomMediumPriority.xlf index dcc4672c33d3..d454131d3807 100644 --- a/DistFiles/localization/hi/BloomMediumPriority.xlf +++ b/DistFiles/localization/hi/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/id/BloomMediumPriority.xlf b/DistFiles/localization/id/BloomMediumPriority.xlf index eccc95428a32..502b5a8ce02b 100644 --- a/DistFiles/localization/id/BloomMediumPriority.xlf +++ b/DistFiles/localization/id/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/km/BloomMediumPriority.xlf b/DistFiles/localization/km/BloomMediumPriority.xlf index 1298a09873da..6d37adaa8132 100644 --- a/DistFiles/localization/km/BloomMediumPriority.xlf +++ b/DistFiles/localization/km/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/ksw/BloomMediumPriority.xlf b/DistFiles/localization/ksw/BloomMediumPriority.xlf index 1a486911bef4..80a7dad6d4bb 100644 --- a/DistFiles/localization/ksw/BloomMediumPriority.xlf +++ b/DistFiles/localization/ksw/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/kw/BloomMediumPriority.xlf b/DistFiles/localization/kw/BloomMediumPriority.xlf index f1593086a45c..4394ceb2274e 100644 --- a/DistFiles/localization/kw/BloomMediumPriority.xlf +++ b/DistFiles/localization/kw/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/ky/BloomMediumPriority.xlf b/DistFiles/localization/ky/BloomMediumPriority.xlf index 7628bc42f43c..0cd8693ddd28 100644 --- a/DistFiles/localization/ky/BloomMediumPriority.xlf +++ b/DistFiles/localization/ky/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/lo/BloomMediumPriority.xlf b/DistFiles/localization/lo/BloomMediumPriority.xlf index fddd1db81e10..cb07ca225ef5 100644 --- a/DistFiles/localization/lo/BloomMediumPriority.xlf +++ b/DistFiles/localization/lo/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/mam/BloomMediumPriority.xlf b/DistFiles/localization/mam/BloomMediumPriority.xlf index 7ef05b379017..b63fa1e6fd5d 100644 --- a/DistFiles/localization/mam/BloomMediumPriority.xlf +++ b/DistFiles/localization/mam/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/my/BloomMediumPriority.xlf b/DistFiles/localization/my/BloomMediumPriority.xlf index 3f04fa270b36..4e2a81813b69 100644 --- a/DistFiles/localization/my/BloomMediumPriority.xlf +++ b/DistFiles/localization/my/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/ne/BloomMediumPriority.xlf b/DistFiles/localization/ne/BloomMediumPriority.xlf index f8312fbc696f..2bf8aecad435 100644 --- a/DistFiles/localization/ne/BloomMediumPriority.xlf +++ b/DistFiles/localization/ne/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/pbu/BloomMediumPriority.xlf b/DistFiles/localization/pbu/BloomMediumPriority.xlf index ae6291ff64a1..93c1d1d7f7ac 100644 --- a/DistFiles/localization/pbu/BloomMediumPriority.xlf +++ b/DistFiles/localization/pbu/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/prs/BloomMediumPriority.xlf b/DistFiles/localization/prs/BloomMediumPriority.xlf index 6a556f72af92..245396b0c9ce 100644 --- a/DistFiles/localization/prs/BloomMediumPriority.xlf +++ b/DistFiles/localization/prs/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/pt/BloomMediumPriority.xlf b/DistFiles/localization/pt/BloomMediumPriority.xlf index 68d309a4d87a..214a18a7e32b 100644 --- a/DistFiles/localization/pt/BloomMediumPriority.xlf +++ b/DistFiles/localization/pt/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Bloqueado por {0} material da capa/verso BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Configurações do livro - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/qaa/BloomMediumPriority.xlf b/DistFiles/localization/qaa/BloomMediumPriority.xlf index 445fe1088213..0bc6d4332cad 100644 --- a/DistFiles/localization/qaa/BloomMediumPriority.xlf +++ b/DistFiles/localization/qaa/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/quc/BloomMediumPriority.xlf b/DistFiles/localization/quc/BloomMediumPriority.xlf index 6acc304f22c3..eac857332775 100644 --- a/DistFiles/localization/quc/BloomMediumPriority.xlf +++ b/DistFiles/localization/quc/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/ru/BloomMediumPriority.xlf b/DistFiles/localization/ru/BloomMediumPriority.xlf index 47e77babe7c4..c69b3c70b8ee 100644 --- a/DistFiles/localization/ru/BloomMediumPriority.xlf +++ b/DistFiles/localization/ru/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/rw/BloomMediumPriority.xlf b/DistFiles/localization/rw/BloomMediumPriority.xlf index 110a04cc70de..6cf567c1f5ca 100644 --- a/DistFiles/localization/rw/BloomMediumPriority.xlf +++ b/DistFiles/localization/rw/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/sw/BloomMediumPriority.xlf b/DistFiles/localization/sw/BloomMediumPriority.xlf index 44164dcce8ae..d091622c8d89 100644 --- a/DistFiles/localization/sw/BloomMediumPriority.xlf +++ b/DistFiles/localization/sw/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/ta/BloomMediumPriority.xlf b/DistFiles/localization/ta/BloomMediumPriority.xlf index d2a2d896f16e..9dbfc86fc82d 100644 --- a/DistFiles/localization/ta/BloomMediumPriority.xlf +++ b/DistFiles/localization/ta/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/te/BloomMediumPriority.xlf b/DistFiles/localization/te/BloomMediumPriority.xlf index 4cc0976c15e0..e56fe6841a30 100644 --- a/DistFiles/localization/te/BloomMediumPriority.xlf +++ b/DistFiles/localization/te/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/tg/BloomMediumPriority.xlf b/DistFiles/localization/tg/BloomMediumPriority.xlf index 4407d5843f18..efbee824a951 100644 --- a/DistFiles/localization/tg/BloomMediumPriority.xlf +++ b/DistFiles/localization/tg/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/th/BloomMediumPriority.xlf b/DistFiles/localization/th/BloomMediumPriority.xlf index 9f917d834bb2..bddefb840849 100644 --- a/DistFiles/localization/th/BloomMediumPriority.xlf +++ b/DistFiles/localization/th/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/tl/BloomMediumPriority.xlf b/DistFiles/localization/tl/BloomMediumPriority.xlf index ae096b8d4cf0..2c283af72451 100644 --- a/DistFiles/localization/tl/BloomMediumPriority.xlf +++ b/DistFiles/localization/tl/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/tr/BloomMediumPriority.xlf b/DistFiles/localization/tr/BloomMediumPriority.xlf index 65e8a45bd737..2f9b360f8e69 100644 --- a/DistFiles/localization/tr/BloomMediumPriority.xlf +++ b/DistFiles/localization/tr/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/uz/BloomMediumPriority.xlf b/DistFiles/localization/uz/BloomMediumPriority.xlf index 173c7959c07e..611155c639bb 100644 --- a/DistFiles/localization/uz/BloomMediumPriority.xlf +++ b/DistFiles/localization/uz/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/vi/BloomMediumPriority.xlf b/DistFiles/localization/vi/BloomMediumPriority.xlf index 9752602ea58b..5b61c90bfa9c 100644 --- a/DistFiles/localization/vi/BloomMediumPriority.xlf +++ b/DistFiles/localization/vi/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/yua/BloomMediumPriority.xlf b/DistFiles/localization/yua/BloomMediumPriority.xlf index 499d3a91c7bf..d91d4a4e3daa 100644 --- a/DistFiles/localization/yua/BloomMediumPriority.xlf +++ b/DistFiles/localization/yua/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/DistFiles/localization/zh-CN/BloomMediumPriority.xlf b/DistFiles/localization/zh-CN/BloomMediumPriority.xlf index 9a0cd79913de..ca85ab2b3da5 100644 --- a/DistFiles/localization/zh-CN/BloomMediumPriority.xlf +++ b/DistFiles/localization/zh-CN/BloomMediumPriority.xlf @@ -804,10 +804,10 @@ Locked by {0} Front/Back matter BookSettings.LockedByXMatter - - Book Settings + + Book and Page Settings Book Settings - BookSettings.Title + BookAndPageSettings.Title Max Image Size diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx new file mode 100644 index 000000000000..de3bc11029c1 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -0,0 +1,481 @@ +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 { ShowEditViewDialog } from "../editViewFrame"; +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; +} + +type IBookSettingsDialogValues = IBookSettings & IPageSettings; + +// 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 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, + ); + + // 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(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 normalizeConfigrSettings = ( + settingsValue: unknown, + ): IBookSettingsDialogValues | undefined => { + if (!settingsValue) { + return undefined; + } + + const parsed = + typeof settingsValue === "string" + ? JSON.parse(settingsValue) + : settingsValue; + + if (typeof parsed !== "object" || !parsed) { + throw new Error("Book settings returned from config-r are invalid"); + } + + return parsed as IBookSettingsDialogValues; + }; + + const removePageSettingsFromConfigrSettings = ( + settingsValue: IBookSettingsDialogValues, + ): 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 liveSettings = + normalizeConfigrSettings(settingsToReturnLater) ?? settings; + // when we're in legacy, we're just going to disable all the appearance controls + setAppearanceDisabled( + liveSettings?.appearance?.cssThemeName === "legacy-5-6", + ); + 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; + + const closeDialogAndClearOpenFlag = React.useCallback(() => { + isOpenAlready = false; + closeDialog(); + }, [closeDialog]); + + const cancelAndCloseDialog = React.useCallback(() => { + if (initialPageAttributeSnapshot.current) { + initialPageAttributeSnapshot.current.restoreToElement( + getCurrentPageElement(), + ); + } + closeDialogAndClearOpenFlag(); + }, [closeDialogAndClearOpenFlag]); + + function saveSettingsAndCloseDialog() { + const latestSettings = normalizeConfigrSettings(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: settingsToReturnLater as + | string + | object + | undefined, + 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. + window.setTimeout(() => { + setSettingsToReturnLater(s); + }, 0); + + if (isInitialConfigrEcho) { + return; + } + + applyPageSettings(parsedPageSettings); + }} + initiallySelectedTopLevelPageIndex={ + props.initiallySelectedGroupIndex + } + > + + {bookSettingsArea.pages} + + + {pageSettingsArea.pages} + + + )} + + + + + + + ); +}; + +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( + , + ); + } +} diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx new file mode 100644 index 000000000000..b1438431505f --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx @@ -0,0 +1,774 @@ +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: string | 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 100% rename from src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx rename to src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx new file mode 100644 index 000000000000..a7d3b2c12849 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx @@ -0,0 +1,452 @@ +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; + 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 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 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 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 getPageNumberBackgroundColor = (): string => { + const page = getCurrentPageElement(); + + const inline = normalizeToHexOrEmpty( + page.style.getPropertyValue("--pageNumber-background-color"), + ); + if (inline) return inline; + + const computed = normalizeToHexOrEmpty( + getComputedStyleForPage(page).getPropertyValue( + "--pageNumber-background-color", + ), + ); + return computed || ""; +}; + +const setPageNumberBackgroundColor = (color: string): void => { + const page = getCurrentPageElement(); + setOrRemoveCustomProperty( + page.style, + "--pageNumber-background-color", + color, + ); +}; + +export const getCurrentPageSettings = (): IPageSettings => { + return { + page: { + backgroundColor: getCurrentPageBackgroundColor(), + pageNumberColor: getPageNumberColor(), + pageNumberBackgroundColor: getPageNumberBackgroundColor(), + }, + }; +}; + +export const applyPageSettings = (settings: IPageSettings): void => { + setCurrentPageBackgroundColor(settings.page.backgroundColor); + setPageNumberColor(settings.page.pageNumberColor); + setPageNumberBackgroundColor(settings.page.pageNumberBackgroundColor); +}; + +export const parsePageSettingsFromConfigrValue = ( + value: unknown, +): IPageSettings => { + const parsed = typeof value === "string" ? JSON.parse(value) : value; + if (typeof parsed !== "object" || !parsed) { + throw new Error("Page settings are not an object"); + } + const parsedRecord = parsed 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 pageNumberBackgroundColor = pageRecord["pageNumberBackgroundColor"]; + + if ( + typeof backgroundColor !== "string" || + typeof pageNumberColor !== "string" || + typeof pageNumberBackgroundColor !== "string" + ) { + throw new Error("Page settings are missing one or more color values"); + } + + return { + page: { + backgroundColor, + pageNumberColor, + 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) && + normalizeToHexOrEmpty(first.page.pageNumberBackgroundColor) === + normalizeToHexOrEmpty(second.page.pageNumberBackgroundColor) + ); +}; + +const PageBackgroundColorPickerForConfigr: React.FunctionComponent<{ + value: string; + disabled?: boolean; + onChange: (value: string) => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const backgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + onChange={(newColor) => props.onChange(newColor)} + /> + ); +}; + +const PageNumberColorPickerForConfigr: React.FunctionComponent<{ + value: string; + disabled?: boolean; + onChange: (value: string) => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const pageNumberColorLabel = useL10n( + "Page Number Color", + "PageSettings.PageNumberColor", + ); + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + onChange={(newColor) => props.onChange(newColor)} + /> + ); +}; + +const PageNumberBackgroundColorPickerForConfigr: React.FunctionComponent<{ + value: string; + disabled?: boolean; + onChange: (value: string) => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const pageNumberBackgroundColorLabel = useL10n( + "Page Number Background Color", + "PageSettings.PageNumberBackgroundColor", + ); + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + onChange={(newColor) => props.onChange(newColor)} + /> + ); +}; + +const PageSettingsConfigrInputs: React.FunctionComponent<{ + disabled?: boolean; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const backgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + const pageNumberColorLabel = useL10n( + "Page Number Color", + "PageSettings.PageNumberColor", + ); + const pageNumberBackgroundColorLabel = useL10n( + "Page Number Background Color", + "PageSettings.PageNumberBackgroundColor", + ); + + const pageBackgroundColorControl = React.useCallback( + (pickerProps: { + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }) => ( + + ), + [props.onColorPickerVisibilityChanged], + ); + + const pageNumberColorControl = React.useCallback( + (pickerProps: { + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }) => ( + + ), + [props.onColorPickerVisibilityChanged], + ); + + const pageNumberBackgroundColorControl = React.useCallback( + (pickerProps: { + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }) => ( + + ), + [props.onColorPickerVisibilityChanged], + ); + + 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: [ + + + + + , + ], + }; +}; 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 8d6be670b32a..c255d42e0b7a 100644 --- a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less +++ b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less @@ -182,15 +182,19 @@ .page-settings-button { display: flex; align-items: center; - justify-content: center; - width: 24px; + justify-content: flex-start; + gap: 6px; + width: auto; height: 24px; - padding: 0; + 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; @@ -199,6 +203,13 @@ svg { width: 20px; height: 20px; + flex-shrink: 0; + } + + .page-settings-button-label { + font-size: 11px; + line-height: 1; + white-space: nowrap; } } 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 eedeff6849bc..41b46c26b6e2 100644 --- a/src/BloomBrowserUI/bookEdit/js/origami.ts +++ b/src/BloomBrowserUI/bookEdit/js/origami.ts @@ -56,13 +56,44 @@ export function setupOrigami() { $("#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(); @@ -372,12 +403,7 @@ ${getPageSettingsButtonHtml()}\ } function getPageSettingsButtonHtml(): string { - // SVG path matches MUI Settings icon - return ``; + return ``; } function pageSettingsButtonClickHandler(e: Event) { diff --git a/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx deleted file mode 100644 index 02e8ce4b3ad4..000000000000 --- a/src/BloomBrowserUI/bookEdit/pageSettings/PageSettingsDialog.tsx +++ /dev/null @@ -1,496 +0,0 @@ -import { css } from "@emotion/react"; -import * as React from "react"; -import { - ConfigrCustomStringInput, - ConfigrGroup, - ConfigrPage, - ConfigrPane, -} from "@sillsdev/config-r"; -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 { useL10n } from "../../react_components/l10nHooks"; -import { - ColorDisplayButton, - DialogResult, -} from "../../react_components/color-picking/colorPickerDialog"; -import { BloomPalette } from "../../react_components/color-picking/bloomPalette"; -import { ElementAttributeSnapshot } from "../../utils/ElementAttributeSnapshot"; -import { getPageIframeBody } from "../../utils/shared"; -import { ShowEditViewDialog } from "../editViewFrame"; -import tinycolor from "tinycolor2"; - -let isOpenAlready = false; - -type IPageSettings = { - page: { - backgroundColor: string; - pageNumberColor: string; - pageNumberBackgroundColor: string; - }; -}; - -const getCurrentPageElement = (): HTMLElement => { - const page = getPageIframeBody()?.querySelector( - ".bloom-page", - ) as HTMLElement | null; - if (!page) { - throw new Error( - "PageSettingsDialog could not find .bloom-page in the page iframe", - ); - } - return page; -}; - -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 ""; - } - - 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 parsePageSettingsFromConfigrValue = (value: unknown): IPageSettings => { - if (typeof value === "string") { - return JSON.parse(value) as IPageSettings; - } - - if (typeof value === "object" && value) { - return value as IPageSettings; - } - - throw new Error( - "PageSettingsDialog: unexpected value from config-r onChange", - ); -}; - -const arePageSettingsEquivalent = ( - first: IPageSettings, - second: IPageSettings, -): boolean => { - return ( - normalizeToHexOrEmpty(first.page.backgroundColor) === - normalizeToHexOrEmpty(second.page.backgroundColor) && - normalizeToHexOrEmpty(first.page.pageNumberColor) === - normalizeToHexOrEmpty(second.page.pageNumberColor) && - normalizeToHexOrEmpty(first.page.pageNumberBackgroundColor) === - normalizeToHexOrEmpty(second.page.pageNumberBackgroundColor) - ); -}; - -const setOrRemoveCustomProperty = ( - style: CSSStyleDeclaration, - propertyName: string, - value: string, -): void => { - const normalized = normalizeToHexOrEmpty(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 getPageNumberBackgroundColor = (): string => { - const page = getCurrentPageElement(); - - const inline = normalizeToHexOrEmpty( - page.style.getPropertyValue("--pageNumber-background-color"), - ); - if (inline) return inline; - - const computed = normalizeToHexOrEmpty( - getComputedStyleForPage(page).getPropertyValue( - "--pageNumber-background-color", - ), - ); - return computed || ""; -}; - -const setPageNumberBackgroundColor = (color: string): void => { - const page = getCurrentPageElement(); - setOrRemoveCustomProperty( - page.style, - "--pageNumber-background-color", - color, - ); -}; - -const applyPageSettings = (settings: IPageSettings): void => { - setCurrentPageBackgroundColor(settings.page.backgroundColor); - setPageNumberColor(settings.page.pageNumberColor); - setPageNumberBackgroundColor(settings.page.pageNumberBackgroundColor); -}; - -const PageBackgroundColorPickerForConfigr: React.FunctionComponent<{ - value: string; - disabled: boolean; - onChange: (value: string) => void; -}> = (props) => { - const backgroundColorLabel = useL10n( - "Background Color", - "Common.BackgroundColor", - ); - - return ( - { - if (dialogResult === DialogResult.OK) props.onChange(newColor); - }} - onChange={(newColor) => props.onChange(newColor)} - /> - ); -}; - -const PageNumberColorPickerForConfigr: React.FunctionComponent<{ - value: string; - disabled: boolean; - onChange: (value: string) => void; -}> = (props) => { - const pageNumberColorLabel = useL10n( - "Page Number Color", - "PageSettings.PageNumberColor", - ); - - return ( - { - if (dialogResult === DialogResult.OK) props.onChange(newColor); - }} - onChange={(newColor) => props.onChange(newColor)} - /> - ); -}; - -const PageNumberBackgroundColorPickerForConfigr: React.FunctionComponent<{ - value: string; - disabled: boolean; - onChange: (value: string) => void; -}> = (props) => { - const pageNumberBackgroundColorLabel = useL10n( - "Page Number Background Color", - "PageSettings.PageNumberBackgroundColor", - ); - - return ( - { - if (dialogResult === DialogResult.OK) props.onChange(newColor); - }} - onChange={(newColor) => props.onChange(newColor)} - /> - ); -}; - -export const PageSettingsDialog: React.FunctionComponent = () => { - const { closeDialog, propsForBloomDialog } = useSetupBloomDialog({ - initiallyOpen: true, - dialogFrameProvidedExternally: false, - }); - - const closeDialogAndClearOpenFlag = React.useCallback(() => { - isOpenAlready = false; - closeDialog(); - }, [closeDialog]); - - const pageSettingsTitle = useL10n("Page Settings", "PageSettings.Title"); - const backgroundColorLabel = useL10n( - "Background Color", - "Common.BackgroundColor", - ); - const pageNumberColorLabel = useL10n( - "Page Number Color", - "PageSettings.PageNumberColor", - ); - const pageNumberBackgroundColorLabel = useL10n( - "Page Number Background Color", - "PageSettings.PageNumberBackgroundColor", - ); - - const [initialValues, setInitialValues] = React.useState< - IPageSettings | undefined - >(undefined); - - const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< - IPageSettings | string | undefined - >(undefined); - - const initialPageAttributeSnapshot = React.useRef< - ElementAttributeSnapshot | undefined - >(undefined); - - // Read after mount so we get the current page's color even if opening this dialog - // is preceded by a save/refresh that updates the page iframe. - React.useEffect(() => { - initialPageAttributeSnapshot.current = - ElementAttributeSnapshot.fromElement(getCurrentPageElement()); - - setInitialValues({ - page: { - backgroundColor: getCurrentPageBackgroundColor(), - pageNumberColor: getPageNumberColor(), - pageNumberBackgroundColor: getPageNumberBackgroundColor(), - }, - }); - }, []); - - const onOk = (): void => { - const rawSettings = settingsToReturnLater ?? initialValues; - if (!rawSettings) { - throw new Error( - "PageSettingsDialog: expected settings to be loaded before OK", - ); - } - - const settings = - typeof rawSettings === "string" - ? (JSON.parse(rawSettings) as IPageSettings) - : rawSettings; - - applyPageSettings(settings); - closeDialogAndClearOpenFlag(); - }; - - const onCancel = ( - _reason?: - | "escapeKeyDown" - | "backdropClick" - | "titleCloseClick" - | "cancelClicked", - ): void => { - if (initialPageAttributeSnapshot.current) { - initialPageAttributeSnapshot.current.restoreToElement( - getCurrentPageElement(), - ); - } - closeDialogAndClearOpenFlag(); - }; - - const onClose = ( - _evt?: object, - _reason?: "escapeKeyDown" | "backdropClick", - ): void => { - onCancel(_reason); - }; - - return ( - - - - {initialValues && ( -
- { - const settings = - parsePageSettingsFromConfigrValue(s); - - if ( - !settingsToReturnLater && - initialValues && - arePageSettingsEquivalent( - settings, - initialValues, - ) - ) { - return; - } - - if (typeof s === "string") { - setSettingsToReturnLater(s); - } else { - setSettingsToReturnLater(settings); - } - - applyPageSettings(settings); - }} - > - - - - - - - - -
- )} -
- - - - -
- ); -}; - -export const showPageSettingsDialog = () => { - if (!isOpenAlready) { - isOpenAlready = true; - ShowEditViewDialog(); - } -}; diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx index 773dfd6a38b3..4fe99845c1ec 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx @@ -15,8 +15,8 @@ import { Bubble, BubbleSpec } from "comicaljs"; import { recomputeSourceBubblesForPage } from "../../js/bloomEditing"; import BloomSourceBubbles from "../../sourceBubbles/BloomSourceBubbles"; import { getToolboxBundleExports } from "../../js/workspaceFrames"; -import { ILanguageNameValues } from "../../bookSettings/FieldVisibilityGroup"; -import { isLegacyThemeCssLoaded } from "../../bookSettings/appearanceThemeUtils"; +import { ILanguageNameValues } from "../../bookAndPageSettings/FieldVisibilityGroup"; +import { isLegacyThemeCssLoaded } from "../../bookAndPageSettings/appearanceThemeUtils"; /* Summary of how custom covers work - a page (currently just cover) which can be customized has a new attribute, diff --git a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts index 277447881a06..d45e739f876f 100644 --- a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts +++ b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts @@ -59,9 +59,8 @@ import { showPageChooserDialog } from "../pageChooser/PageChooserDialog"; export { showPageChooserDialog }; import "../lib/errorHandler"; -import { showBookSettingsDialog } from "./bookSettings/BookSettingsDialog"; +import { showBookSettingsDialog } from "./bookAndPageSettings/BookAndPageSettingsDialog"; export { showBookSettingsDialog }; -import { showPageSettingsDialog } from "./pageSettings/PageSettingsDialog"; import { showRegistrationDialogForEditTab } from "../react_components/registration/registrationDialog"; export { showRegistrationDialogForEditTab as showRegistrationDialog }; import { showAboutDialog } from "../react_components/aboutDialog"; @@ -277,7 +276,8 @@ export function showEditViewBookSettingsDialog( export function showAboutDialogFromWorkspaceRoot() { export function showEditViewPageSettingsDialog() { - showPageSettingsDialog(); + // Book pages are first (0-4); Page > Colors is index 5. + showBookSettingsDialog(5); } export function showAboutDialogInEditTab() { 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/package.json b/src/BloomBrowserUI/package.json index d181025c93a1..9c529decdff7 100644 --- a/src/BloomBrowserUI/package.json +++ b/src/BloomBrowserUI/package.json @@ -126,7 +126,7 @@ "@nivo/core": "^0.80.0", "@nivo/scatterplot": "^0.80.0", "@nivo/tooltip": "^0.80.0", - "@sillsdev/config-r": "1.0.0-alpha.19", + "@sillsdev/config-r": "1.0.0-alpha.21", "@types/filesize": "^5.0.0", "@types/react-transition-group": "^4.4.1", "@use-it/event-listener": "^0.1.7", diff --git a/src/BloomBrowserUI/react_components/BookInfoIndicator.tsx b/src/BloomBrowserUI/react_components/BookInfoIndicator.tsx index 5005cd85a330..3a539190ffd3 100644 --- a/src/BloomBrowserUI/react_components/BookInfoIndicator.tsx +++ b/src/BloomBrowserUI/react_components/BookInfoIndicator.tsx @@ -12,8 +12,8 @@ import { MessageUsingMigratedThemeInsteadOfIncompatibleCss, MessageUsingLegacyThemeWithIncompatibleCss, MessageIgnoringIncompatibleCssCanDelete, -} from "../bookEdit/bookSettings/BookSettingsDialog"; -import { isLegacyThemeName } from "../bookEdit/bookSettings/appearanceThemeUtils"; +} from "../bookEdit/bookAndPageSettings/BookSettingsConfigrPages"; +import { isLegacyThemeName } from "../bookEdit/bookAndPageSettings/appearanceThemeUtils"; export const BookInfoIndicator: React.FunctionComponent<{ bookId: string; diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx index d855487b47fe..4a80045da69a 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx @@ -169,9 +169,17 @@ export const ColorPicker: React.FunctionComponent = ( // 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: props.currentColor.opacity, // Don't change opacity + colors: [colorOnly], + opacity: newOpacity, }; changeColor(newColor); }; @@ -314,6 +322,7 @@ export const ColorPicker: React.FunctionComponent = ( = (props) => { if (eyedropperActive) { return; } - if (reason === "backdropClick") - onClose(DialogResult.OK); if (reason === "escapeKeyDown") onClose(DialogResult.Cancel); }} @@ -490,12 +488,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; @@ -508,21 +515,29 @@ export interface IColorDisplayButtonProps { disabled?: 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 [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(props.initialColor), - [props.initialColor], + () => + getColorInfoFromSpecialNameOrColorString( + dialogOpen ? colorAtDialogOpen : props.initialColor, + ), + [props.initialColor, dialogOpen, colorAtDialogOpen], ); useEffect(() => { @@ -536,6 +551,15 @@ 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 (
= ( `} onClick={() => { if (props.disabled) return; + if (onColorPickerVisibilityChanged) { + onColorPickerVisibilityChanged(true); + } + setColorAtDialogOpen(props.initialColor); setDialogOpen(true); }} /> @@ -565,11 +593,17 @@ export const ColorDisplayButton: React.FC = ( open={dialogOpen} close={(result: DialogResult) => { 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} @@ -578,7 +612,7 @@ export const ColorDisplayButton: React.FC = ( initialColor={initialColorInfo} onInputFocus={() => {}} onChange={(color: IColorInfo) => { - const newColor = color.colors[0]; + const newColor = getColorStringFromColorInfo(color); setCurrentButtonColor(newColor); if (props.onChange) { props.onChange(newColor); diff --git a/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx b/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx index df96b6db2510..3e3a03422b9b 100644 --- a/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx @@ -7,16 +7,21 @@ import { IColorInfo } from "./colorSwatch"; interface IHexColorInputProps { initial: IColorInfo; onChangeComplete: (newValue: string) => void; + includeOpacityChannel?: boolean; } const hashChar = "#"; -const massageColorInput = (color: string): string => { +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 - if (result.length > 7) { - result = result.slice(0, 7); + const maxLength = includeOpacityChannel ? 9 : 7; + if (result.length > maxLength) { + result = result.slice(0, maxLength); } return result; }; @@ -24,28 +29,47 @@ const massageColorInput = (color: string): string => { // 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): string => { +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]; - 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 hexColor = tinycolor(firstColor).toHexString(); + + if (!includeOpacityChannel) { + return hexColor; + } -const getInitialHexValue = (colorInfo: IColorInfo): string => { - return massageColorInput(getHexColorValueFromColorInfo(colorInfo)); + const alphaHex = Math.round(colorInfo.opacity * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `${hexColor}${alphaHex}`; }; export const HexColorInput: React.FunctionComponent = ( props, ) => { + const getHexValue = React.useCallback( + (colorInfo: IColorInfo): string => + massageColorInput( + getHexColorValueFromColorInfo( + colorInfo, + props.includeOpacityChannel, + ), + props.includeOpacityChannel, + ), + [props.includeOpacityChannel], + ); + const [currentColor, setCurrentColor] = useState(() => - getInitialHexValue(props.initial), + getHexValue(props.initial), ); - const initialHexValue = getInitialHexValue(props.initial); + 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). @@ -56,15 +80,19 @@ export const HexColorInput: React.FunctionComponent = ( 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/yarn.lock b/src/BloomBrowserUI/yarn.lock index 02427ae70006..ea052be7a1cf 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.19": - version "1.0.0-alpha.19" - resolved "https://registry.npmjs.org/@sillsdev/config-r/-/config-r-1.0.0-alpha.19.tgz#012888b7309e87b970d9b423c616eed395315ff7" - integrity sha512-tKTaFS2MwJ7z1dmUWXGCej59g6e1lLXzpJq0LG/Yov+P0EdOB4m32wxS2QMscz17/TBUy2IFNh4UkjTmRjNMNg== +"@sillsdev/config-r@1.0.0-alpha.21": + version "1.0.0-alpha.21" + resolved "https://registry.npmjs.org/@sillsdev/config-r/-/config-r-1.0.0-alpha.21.tgz#174853f448f949768a6db6415b45b592a1ec395f" + integrity sha512-7F1blmY1eirSIaPA+0QE324Fv6McqVkbx+fK7Eg1k4lH9I1aO0KXdCmfBcZkEUSnAINrQ2mqlvPJwvWiLZHUuw== dependencies: "@textea/json-viewer" "^2.13.1" formik "^2.2.9" From 2a8e3cc365ee229e14e7fa4cefec2913bae8e4ef Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 5 Mar 2026 19:20:28 -0700 Subject: [PATCH 08/17] Review fixes --- .../BookAndPageSettingsDialog.tsx | 43 ++-- .../BookSettingsConfigrPages.tsx | 2 +- .../FieldVisibilityGroup.tsx | 10 +- .../PageSettingsConfigrPages.tsx | 187 +++++++----------- src/BloomBrowserUI/bookEdit/js/origami.ts | 47 +++-- .../collection/CollectionSettingsDialog.tsx | 31 +-- src/BloomExe/Book/HtmlDom.cs | 43 +++- src/BloomExe/Edit/EditingView.cs | 5 - .../efl-zeromargin1/customBookStyles.css | 2 +- 9 files changed, 171 insertions(+), 199 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index de3bc11029c1..fe49d3fdfe30 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -167,8 +167,9 @@ export const BookSettingsDialog: React.FunctionComponent<{ IPageSettings | undefined >(undefined); - const [settingsToReturnLater, setSettingsToReturnLater] = - React.useState(undefined); + const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< + ConfigrValues | undefined + >(undefined); const dialogRef = React.useRef(null); const setDialogVisibleWhileColorPickerOpen = React.useCallback( @@ -188,27 +189,8 @@ export const BookSettingsDialog: React.FunctionComponent<{ [], ); - const normalizeConfigrSettings = ( - settingsValue: unknown, - ): IBookSettingsDialogValues | undefined => { - if (!settingsValue) { - return undefined; - } - - const parsed = - typeof settingsValue === "string" - ? JSON.parse(settingsValue) - : settingsValue; - - if (typeof parsed !== "object" || !parsed) { - throw new Error("Book settings returned from config-r are invalid"); - } - - return parsed as IBookSettingsDialogValues; - }; - const removePageSettingsFromConfigrSettings = ( - settingsValue: IBookSettingsDialogValues, + settingsValue: ConfigrValues, ): IBookSettings => { const settingsWithoutPage = { ...settingsValue, @@ -279,13 +261,15 @@ export const BookSettingsDialog: React.FunctionComponent<{ React.useEffect(() => { if (settings?.appearance) { - const liveSettings = - normalizeConfigrSettings(settingsToReturnLater) ?? settings; + 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( - liveSettings?.appearance?.cssThemeName === "legacy-5-6", + liveAppearance?.cssThemeName === "legacy-5-6", ); - setTheme(liveSettings?.appearance?.cssThemeName ?? ""); + setTheme(liveAppearance?.cssThemeName ?? ""); } }, [settings, settingsToReturnLater]); @@ -317,7 +301,7 @@ export const BookSettingsDialog: React.FunctionComponent<{ }, [closeDialogAndClearOpenFlag]); function saveSettingsAndCloseDialog() { - const latestSettings = normalizeConfigrSettings(settingsToReturnLater); + const latestSettings = settingsToReturnLater; if (latestSettings) { applyPageSettings( parsePageSettingsFromConfigrValue(latestSettings), @@ -341,10 +325,7 @@ export const BookSettingsDialog: React.FunctionComponent<{ tierAllowsFullBleed, pageSizeSupportsFullBleed, settings, - settingsToReturnLater: settingsToReturnLater as - | string - | object - | undefined, + settingsToReturnLater, getAdditionalProps, firstPossiblyLegacyCss, theme, diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx index b1438431505f..80034779437f 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx @@ -46,7 +46,7 @@ type BookSettingsAreaProps = { tierAllowsFullBleed?: boolean; pageSizeSupportsFullBleed: boolean; settings: object | undefined; - settingsToReturnLater: string | object | undefined; + settingsToReturnLater: object | undefined; getAdditionalProps: (subPath: string) => { path: string; overrideValue: T; diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx index de7d6d65455b..fb6578287dd8 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/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 index a7d3b2c12849..c3706427cb04 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx @@ -188,11 +188,10 @@ export const applyPageSettings = (settings: IPageSettings): void => { export const parsePageSettingsFromConfigrValue = ( value: unknown, ): IPageSettings => { - const parsed = typeof value === "string" ? JSON.parse(value) : value; - if (typeof parsed !== "object" || !parsed) { + if (typeof value !== "object" || !value) { throw new Error("Page settings are not an object"); } - const parsedRecord = parsed as Record; + const parsedRecord = value as Record; const pageValues = parsedRecord["page"]; if (typeof pageValues !== "object" || !pageValues) { @@ -236,54 +235,30 @@ export const arePageSettingsEquivalent = ( ); }; -const PageBackgroundColorPickerForConfigr: React.FunctionComponent<{ +type IConfigrColorPickerControlProps = { value: string; disabled?: boolean; onChange: (value: string) => void; - onColorPickerVisibilityChanged?: (open: boolean) => void; -}> = (props) => { - const backgroundColorLabel = useL10n( - "Background Color", - "Common.BackgroundColor", - ); - - return ( - { - if (dialogResult === DialogResult.OK) props.onChange(newColor); - }} - onChange={(newColor) => props.onChange(newColor)} - /> - ); }; -const PageNumberColorPickerForConfigr: React.FunctionComponent<{ - value: string; - disabled?: boolean; - onChange: (value: string) => void; - onColorPickerVisibilityChanged?: (open: boolean) => void; -}> = (props) => { - const pageNumberColorLabel = useL10n( - "Page Number Color", - "PageSettings.PageNumberColor", - ); +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 ( void; onColorPickerVisibilityChanged?: (open: boolean) => void; }> = (props) => { - const pageNumberBackgroundColorLabel = useL10n( - "Page Number Background Color", - "PageSettings.PageNumberBackgroundColor", + const colorControl = React.useCallback( + (pickerProps: IConfigrColorPickerControlProps) => ( + + ), + [ + props.emptyValueDisplayColor, + props.localizedTitle, + props.onColorPickerVisibilityChanged, + props.palette, + props.transparency, + ], ); return ( - { - if (dialogResult === DialogResult.OK) props.onChange(newColor); - }} - onChange={(newColor) => props.onChange(newColor)} + ); }; @@ -343,73 +330,41 @@ const PageSettingsConfigrInputs: React.FunctionComponent<{ "PageSettings.PageNumberBackgroundColor", ); - const pageBackgroundColorControl = React.useCallback( - (pickerProps: { - value: string; - disabled?: boolean; - onChange: (value: string) => void; - }) => ( - - ), - [props.onColorPickerVisibilityChanged], - ); - - const pageNumberColorControl = React.useCallback( - (pickerProps: { - value: string; - disabled?: boolean; - onChange: (value: string) => void; - }) => ( - - ), - [props.onColorPickerVisibilityChanged], - ); - - const pageNumberBackgroundColorControl = React.useCallback( - (pickerProps: { - value: string; - disabled?: boolean; - onChange: (value: string) => void; - }) => ( - - ), - [props.onColorPickerVisibilityChanged], - ); - return ( <> - - - ); diff --git a/src/BloomBrowserUI/bookEdit/js/origami.ts b/src/BloomBrowserUI/bookEdit/js/origami.ts index 41b46c26b6e2..78bfe094c5a9 100644 --- a/src/BloomBrowserUI/bookEdit/js/origami.ts +++ b/src/BloomBrowserUI/bookEdit/js/origami.ts @@ -21,29 +21,41 @@ 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 @@ -368,7 +380,7 @@ function getSplitPaneComponentInner() { return spci; } -function getAbovePageControlContainer(): JQuery { +function getAbovePageControlContainer(showOrigamiControls: boolean): 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. // Note: We also have to disable the Choose Different layout option in @@ -384,6 +396,15 @@ ${getPageSettingsButtonHtml()}\
`, ); } + + if (!showOrigamiControls) { + return $( + `
\ +${getPageSettingsButtonHtml()}\ +
`, + ); + } + return $( `\
\ 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/BloomExe/Book/HtmlDom.cs b/src/BloomExe/Book/HtmlDom.cs index 1bc941267c8d..aa67eb64ea4a 100644 --- a/src/BloomExe/Book/HtmlDom.cs +++ b/src/BloomExe/Book/HtmlDom.cs @@ -1886,6 +1886,43 @@ 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-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,9 +1958,9 @@ SafeXmlElement edittedPageDiv //html file in a browser. destinationPageDiv.SetAttribute("lang", edittedPageDiv.GetAttribute("lang")); - // Allow saving per-page CSS custom properties (e.g. --page-background-color) stored on the page div. - // If missing, remove any previously-saved style. - var style = edittedPageDiv.GetAttribute("style"); + // 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 diff --git a/src/BloomExe/Edit/EditingView.cs b/src/BloomExe/Edit/EditingView.cs index bd7408020098..d5b197f9ffcf 100644 --- a/src/BloomExe/Edit/EditingView.cs +++ b/src/BloomExe/Edit/EditingView.cs @@ -1735,11 +1735,6 @@ public void SaveAndOpenPageSettingsDialog() ); } - private void _pageSettingsButton_Click(object sender, EventArgs e) - { - SaveAndOpenPageSettingsDialog(); - } - // This is temporary code we added in 6.0 when trying to determine why we are sometimes losing // user data upon save. See BL-13120. private void _topBarPanel_Click(object sender, EventArgs e) diff --git a/src/content/appearanceMigrations/efl-zeromargin1/customBookStyles.css b/src/content/appearanceMigrations/efl-zeromargin1/customBookStyles.css index 6a7f94f5558a..c70b168314f5 100644 --- a/src/content/appearanceMigrations/efl-zeromargin1/customBookStyles.css +++ b/src/content/appearanceMigrations/efl-zeromargin1/customBookStyles.css @@ -28,7 +28,7 @@ --pageNumber-color: black; --pageNumber-background-width: 17px; --pageNumber-border-radius: 50%; - --pageNumber-background-color: transparent; + --pageNumber-background-color: #ffffff; font-family: "ABeeZee"; z-index: 1000; From cefe01a642fbb02243246e770504b2e2abd81882 Mon Sep 17 00:00:00 2001 From: hatton Date: Fri, 6 Mar 2026 15:16:54 +0000 Subject: [PATCH 09/17] Mark stale BookAndPageSettings.Title translations as needs-translation --- DistFiles/localization/es/BloomMediumPriority.xlf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DistFiles/localization/es/BloomMediumPriority.xlf b/DistFiles/localization/es/BloomMediumPriority.xlf index 0ff83064a00e..5dcfc6a7635a 100644 --- a/DistFiles/localization/es/BloomMediumPriority.xlf +++ b/DistFiles/localization/es/BloomMediumPriority.xlf @@ -804,9 +804,9 @@ Bloqueado por {0} material de las páginas de inicio y final BookSettings.LockedByXMatter - + Book and Page Settings - Configuración del libro + Configuración del libro BookAndPageSettings.Title From ba3ac1f1b57fd7b4bc5b0b2c27eb5e03738d9398 Mon Sep 17 00:00:00 2001 From: Hatton Date: Fri, 6 Mar 2026 10:31:07 -0700 Subject: [PATCH 10/17] Review fixes --- .../BookAndPageSettingsDialog.tsx | 16 +++++++--------- src/BloomBrowserUI/bookEdit/workspaceRoot.ts | 10 ++++++---- src/BloomBrowserUI/package.json | 2 +- src/BloomBrowserUI/yarn.lock | 8 ++++---- src/BloomExe/Edit/EditingView.cs | 6 +++--- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index fe49d3fdfe30..24edbf7f4234 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -60,8 +60,6 @@ export interface IBookSettings { firstPossiblyLegacyCss?: string; } -type IBookSettingsDialogValues = IBookSettings & IPageSettings; - // 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 @@ -73,8 +71,8 @@ interface IOverrideInformation { xmatterName: string; } -export const BookSettingsDialog: React.FunctionComponent<{ - initiallySelectedGroupIndex?: number; +export const BookAndPageSettingsDialog: React.FunctionComponent<{ + initiallySelectedPageKey?: string; }> = (props) => { const { closeDialog, propsForBloomDialog } = useSetupBloomDialog({ initiallyOpen: true, @@ -415,8 +413,8 @@ export const BookSettingsDialog: React.FunctionComponent<{ applyPageSettings(parsedPageSettings); }} - initiallySelectedTopLevelPageIndex={ - props.initiallySelectedGroupIndex + initiallySelectedTopLevelPageKey={ + props.initiallySelectedPageKey } > , ); } diff --git a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts index d45e739f876f..deda63a3839e 100644 --- a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts +++ b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts @@ -269,15 +269,17 @@ export function showEditViewTopicChooserDialog() { showTopicChooserDialog(); } export function showEditViewBookSettingsDialog( - initiallySelectedGroupIndex?: number, + initiallySelectedPageKey?: string, ) { - showBookSettingsDialog(initiallySelectedGroupIndex); + showBookSettingsDialog(initiallySelectedPageKey); } export function showAboutDialogFromWorkspaceRoot() { + showAboutDialog(); +} + export function showEditViewPageSettingsDialog() { - // Book pages are first (0-4); Page > Colors is index 5. - showBookSettingsDialog(5); + showBookSettingsDialog("colors"); } export function showAboutDialogInEditTab() { diff --git a/src/BloomBrowserUI/package.json b/src/BloomBrowserUI/package.json index 9c529decdff7..7d76a29d3435 100644 --- a/src/BloomBrowserUI/package.json +++ b/src/BloomBrowserUI/package.json @@ -126,7 +126,7 @@ "@nivo/core": "^0.80.0", "@nivo/scatterplot": "^0.80.0", "@nivo/tooltip": "^0.80.0", - "@sillsdev/config-r": "1.0.0-alpha.21", + "@sillsdev/config-r": "1.0.0-alpha.22", "@types/filesize": "^5.0.0", "@types/react-transition-group": "^4.4.1", "@use-it/event-listener": "^0.1.7", diff --git a/src/BloomBrowserUI/yarn.lock b/src/BloomBrowserUI/yarn.lock index ea052be7a1cf..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.21": - version "1.0.0-alpha.21" - resolved "https://registry.npmjs.org/@sillsdev/config-r/-/config-r-1.0.0-alpha.21.tgz#174853f448f949768a6db6415b45b592a1ec395f" - integrity sha512-7F1blmY1eirSIaPA+0QE324Fv6McqVkbx+fK7Eg1k4lH9I1aO0KXdCmfBcZkEUSnAINrQ2mqlvPJwvWiLZHUuw== +"@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/Edit/EditingView.cs b/src/BloomExe/Edit/EditingView.cs index d5b197f9ffcf..f1ec40b7beb8 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});" + $"editTabBundle.showEditViewBookSettingsDialog('{pageKey}');" ); return _model.CurrentPage.Id; }, From d50d290ce0cdbd360f555adae35659aa12d79af9 Mon Sep 17 00:00:00 2001 From: Hatton Date: Fri, 6 Mar 2026 10:32:26 -0700 Subject: [PATCH 11/17] draft skill for replying to reviewable --- .../skills/reviewable-thread-replies/SKILL.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 .github/skills/reviewable-thread-replies/SKILL.md 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 +``` + From 5d1f1a7bbc67a6a12fb39b5875d83a057fdbdcf6 Mon Sep 17 00:00:00 2001 From: Hatton Date: Wed, 11 Mar 2026 15:22:38 -0600 Subject: [PATCH 12/17] Fixes post rebase The rebase had to deal with "Single Browser" and conceivably "More Vite Dev". Afterwards I couldn't get into the edit tab without errors, so I gave it the whole console log and it made these changes. This should be carefully reviewed. --- .../BookAndPageSettingsDialog.tsx | 17 ++++++++++------ .../bookEdit/js/workspaceFrames.ts | 6 +++--- .../pageThumbnailList/PageThumbnail.tsx | 4 ++++ .../pageThumbnailList/pageThumbnailList.tsx | 20 +++++++++++++++---- .../bookEdit/toolbox/toolbox.ts | 8 ++++++++ src/BloomBrowserUI/bookEdit/workspaceRoot.ts | 1 + .../react_components/bloomButton.tsx | 3 +++ src/BloomExe/Edit/EditingView.cs | 4 ++-- 8 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx index 24edbf7f4234..3500813e40d0 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -21,7 +21,7 @@ import { useApiStringState, } from "../../utils/bloomApi"; import { useL10n } from "../../react_components/l10nHooks"; -import { ShowEditViewDialog } from "../editViewFrame"; +import { getWorkspaceBundleExports } from "../js/workspaceFrames"; import { ElementAttributeSnapshot } from "../../utils/ElementAttributeSnapshot"; import { useGetFeatureStatus } from "../../react_components/featureStatus"; import { @@ -451,10 +451,15 @@ export function showBookSettingsDialog(initiallySelectedPageKey?: string) { // for now, we need to prevent that. if (!isOpenAlready) { isOpenAlready = true; - ShowEditViewDialog( - , - ); + try { + getWorkspaceBundleExports().ShowEditViewDialog( + , + ); + } catch (error) { + isOpenAlready = false; + throw error; + } } } 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 ( -
+
setCurrentTool(toolID), 0); + return; + } const accordionHeaders = toolbox.find("> h3"); if (toolID) { diff --git a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts index deda63a3839e..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; diff --git a/src/BloomBrowserUI/react_components/bloomButton.tsx b/src/BloomBrowserUI/react_components/bloomButton.tsx index f9660b3b5097..2dafe81f211a 100644 --- a/src/BloomBrowserUI/react_components/bloomButton.tsx +++ b/src/BloomBrowserUI/react_components/bloomButton.tsx @@ -75,10 +75,13 @@ export default class BloomButton extends LocalizableElement< l10nComment, clickApiEndpoint, mightNavigate, + transparent, enabledImageFile, disabledImageFile, hasText, iconBeforeText, + l10nTipEnglishEnabled, + l10nTipEnglishDisabled, temporarilyDisableI18nWarning, alreadyLocalized, ...propsToPass diff --git a/src/BloomExe/Edit/EditingView.cs b/src/BloomExe/Edit/EditingView.cs index f1ec40b7beb8..be95506492a2 100644 --- a/src/BloomExe/Edit/EditingView.cs +++ b/src/BloomExe/Edit/EditingView.cs @@ -1715,7 +1715,7 @@ public void SaveAndOpenBookSettingsDialog() // Open the book settings dialog to the context-specific page. var pageKey = _model.CurrentPage.IsCoverPage ? "cover" : "contentPages"; RunJavascriptAsync( - $"editTabBundle.showEditViewBookSettingsDialog('{pageKey}');" + $"workspaceBundle.showEditViewBookSettingsDialog('{pageKey}');" ); return _model.CurrentPage.Id; }, @@ -1728,7 +1728,7 @@ public void SaveAndOpenPageSettingsDialog() _model.SaveThen( () => { - RunJavascriptAsync("editTabBundle.showEditViewPageSettingsDialog();"); + RunJavascriptAsync("workspaceBundle.showEditViewPageSettingsDialog();"); return _model.CurrentPage.Id; }, () => { } // wrong state, do nothing From bbb55b0fe5016ba102c3d5fd413137a436c3adc1 Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 12 Mar 2026 16:08:47 -0600 Subject: [PATCH 13/17] Add page number outline color support --- .../localization/en/BloomMediumPriority.xlf | 20 ++ .../PageSettingsConfigrPages.tsx | 228 +++++++++++++----- src/BloomExe/Book/AppearanceSettings.cs | 2 + src/BloomExe/Book/HtmlDom.cs | 1 + .../Book/AppearanceSettingsTests.cs | 19 ++ .../appearance-theme-default.css | 1 + src/content/bookLayout/pageNumbers.less | 2 + 7 files changed, 218 insertions(+), 55 deletions(-) diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index 2a540cf4dd93..7830bba16954 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -757,6 +757,11 @@ ID: BookAndPageSettings.Colors Label for the page-level Colors page within the combined Book and Page Settings dialog. + + Page Number + ID: BookAndPageSettings.PageNumberGroup + Group label for the page-level page number color settings in the combined Book and Page Settings dialog. + Page Settings ID: PageSettings.Title @@ -767,6 +772,21 @@ ID: PageSettings.OpenTooltip Tooltip shown when hovering over the Page Settings button above a custom page. + + Outline Color + ID: PageSettings.OutlineColor + Label for the page-level page number outline color setting. + + + Use an outline color when the page number needs more contrast against the page. + ID: PageSettings.PageNumberOutlineColor.Description + Help text shown below the page-level page number outline color setting in the combined Book and Page Settings dialog. + + + 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. + ID: PageSettings.PageNumberBackgroundColor.Description + Help text shown below the page-level page number background color setting in the combined Book and Page Settings dialog. + Max Image Size BookSettings.eBook.Image.MaxResolution diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx index c3706427cb04..0e4304c28df9 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx @@ -17,6 +17,7 @@ export type IPageSettings = { page: { backgroundColor: string; pageNumberColor: string; + pageNumberOutlineColor: string; pageNumberBackgroundColor: string; }; }; @@ -33,6 +34,8 @@ export const getCurrentPageElement = (): HTMLElement => { return page; }; +const kTransparentCssValue = "transparent"; + const normalizeToHexOrEmpty = (color: string): string => { const trimmed = color.trim(); if (!trimmed) { @@ -56,6 +59,28 @@ const normalizeToHexOrEmpty = (color: string): string => { 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) { @@ -115,6 +140,19 @@ const setOrRemoveCustomProperty = ( } }; +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); @@ -144,25 +182,45 @@ const setPageNumberColor = (color: string): void => { setOrRemoveCustomProperty(page.style, "--pageNumber-color", color); }; -const getPageNumberBackgroundColor = (): string => { +const getPageNumberOutlineColor = (): string => { const page = getCurrentPageElement(); - const inline = normalizeToHexOrEmpty( - page.style.getPropertyValue("--pageNumber-background-color"), + const inline = normalizeToHexOrTransparentOrEmpty( + page.style.getPropertyValue("--pageNumber-outline-color"), ); if (inline) return inline; - const computed = normalizeToHexOrEmpty( + const computed = normalizeToHexOrTransparentOrEmpty( getComputedStyleForPage(page).getPropertyValue( - "--pageNumber-background-color", + "--pageNumber-outline-color", ), ); - return computed || ""; + 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(); - setOrRemoveCustomProperty( + setOrRemoveCustomPropertyAllowTransparent( page.style, "--pageNumber-background-color", color, @@ -174,6 +232,7 @@ export const getCurrentPageSettings = (): IPageSettings => { page: { backgroundColor: getCurrentPageBackgroundColor(), pageNumberColor: getPageNumberColor(), + pageNumberOutlineColor: getPageNumberOutlineColor(), pageNumberBackgroundColor: getPageNumberBackgroundColor(), }, }; @@ -182,6 +241,7 @@ export const getCurrentPageSettings = (): IPageSettings => { export const applyPageSettings = (settings: IPageSettings): void => { setCurrentPageBackgroundColor(settings.page.backgroundColor); setPageNumberColor(settings.page.pageNumberColor); + setPageNumberOutlineColor(settings.page.pageNumberOutlineColor); setPageNumberBackgroundColor(settings.page.pageNumberBackgroundColor); }; @@ -202,11 +262,13 @@ export const parsePageSettingsFromConfigrValue = ( 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"); @@ -216,6 +278,7 @@ export const parsePageSettingsFromConfigrValue = ( page: { backgroundColor, pageNumberColor, + pageNumberOutlineColor, pageNumberBackgroundColor, }, }; @@ -230,8 +293,18 @@ export const arePageSettingsEquivalent = ( normalizeToHexOrEmpty(second.page.backgroundColor) && normalizeToHexOrEmpty(first.page.pageNumberColor) === normalizeToHexOrEmpty(second.page.pageNumberColor) && - normalizeToHexOrEmpty(first.page.pageNumberBackgroundColor) === - normalizeToHexOrEmpty(second.page.pageNumberBackgroundColor) + normalizeToHexOrTransparentOrEmpty( + first.page.pageNumberOutlineColor, + ) === + normalizeToHexOrTransparentOrEmpty( + second.page.pageNumberOutlineColor, + ) && + normalizeToHexOrTransparentOrEmpty( + first.page.pageNumberBackgroundColor, + ) === + normalizeToHexOrTransparentOrEmpty( + second.page.pageNumberBackgroundColor, + ) ); }; @@ -260,6 +333,7 @@ const ConfigrColorPickerControl: React.FunctionComponent< transparency={props.transparency} palette={props.palette} width={75} + deferOnChangeUntilComplete={true} onColorPickerVisibilityChanged={ props.onColorPickerVisibilityChanged } @@ -274,6 +348,7 @@ const ConfigrColorPickerControl: React.FunctionComponent< const PageSettingsConfigrColorInput: React.FunctionComponent<{ label: string; path: string; + description?: string; localizedTitle: string; transparency: boolean; palette: BloomPalette; @@ -307,13 +382,14 @@ const PageSettingsConfigrColorInput: React.FunctionComponent<{ ); }; -const PageSettingsConfigrInputs: React.FunctionComponent<{ +const PageConfigrInputs: React.FunctionComponent<{ disabled?: boolean; onColorPickerVisibilityChanged?: (open: boolean) => void; }> = (props) => { @@ -321,55 +397,92 @@ const PageSettingsConfigrInputs: React.FunctionComponent<{ "Background Color", "Common.BackgroundColor", ); - const pageNumberColorLabel = useL10n( - "Page Number Color", - "PageSettings.PageNumberColor", - ); - const pageNumberBackgroundColorLabel = useL10n( - "Page Number Background Color", - "PageSettings.PageNumberBackgroundColor", - ); 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; @@ -394,13 +507,18 @@ export const usePageSettingsAreaDefinition = (props: { 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/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 aa67eb64ea4a..56e2793638f7 100644 --- a/src/BloomExe/Book/HtmlDom.cs +++ b/src/BloomExe/Book/HtmlDom.cs @@ -1891,6 +1891,7 @@ public static void RemoveTemplateEditingMarkup(SafeXmlElement editedPageDiv) "--page-background-color", "--marginBox-background-color", "--pageNumber-color", + "--pageNumber-outline-color", "--pageNumber-background-color", }; 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 f7d7274e5a36..3934d5fd244a 100644 --- a/src/content/appearanceThemes/appearance-theme-default.css +++ b/src/content/appearanceThemes/appearance-theme-default.css @@ -41,6 +41,7 @@ --pageNumber-background-color: transparent; /* color: value in .numberedPage:after to display the page number */ --pageNumber-color: black; + --pageNumber-outline-color: white; /* 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/bookLayout/pageNumbers.less b/src/content/bookLayout/pageNumbers.less index 13b31b1e0b54..d0036cdf84fa 100644 --- a/src/content/bookLayout/pageNumbers.less +++ b/src/content/bookLayout/pageNumbers.less @@ -24,6 +24,8 @@ 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; From b508c1091089d4c9f05bc77ac814006296b7f754 Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 12 Mar 2026 16:12:21 -0600 Subject: [PATCH 14/17] Improve color picker responsiveness --- .../BookSettingsConfigrPages.tsx | 1 + .../color-picking/bloomSketchPicker.tsx | 1 + .../color-picking/colorPicker.tsx | 37 ++++++++---- .../color-picking/colorPickerDialog.tsx | 49 ++++++++++++--- .../colorDisplayButton.uitest.ts | 59 +++++++++++++++++++ .../colorDisplayButtonTestHarness.tsx | 28 ++++++++- .../component-tests/colorPicker.uitest.ts | 54 +++++++++++++++-- 7 files changed, 201 insertions(+), 28 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx index 80034779437f..fe6c494c4d93 100644 --- a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx @@ -687,6 +687,7 @@ const CoverColorPickerForConfigr: React.FunctionComponent<{ transparency={false} palette={BloomPalette.CoverBackground} width={75} + deferOnChangeUntilComplete={true} onColorPickerVisibilityChanged={ props.onColorPickerVisibilityChanged } diff --git a/src/BloomBrowserUI/react_components/color-picking/bloomSketchPicker.tsx b/src/BloomBrowserUI/react_components/color-picking/bloomSketchPicker.tsx index 12e8b4d7c825..4630728c6d60 100644 --- a/src/BloomBrowserUI/react_components/color-picking/bloomSketchPicker.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/bloomSketchPicker.tsx @@ -9,6 +9,7 @@ interface IBloomSketchPickerProps { noAlphaSlider?: boolean; onChange: (color: ColorResult) => 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 4a80045da69a..657ffe25f65b 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx @@ -18,6 +18,7 @@ interface IColorPickerProps { transparency?: boolean; noGradientSwatches?: boolean; onChange: (color: IColorInfo) => void; + onChangeComplete?: (color: IColorInfo) => void; currentColor: IColorInfo; swatchColors: IColorInfo[]; includeDefault?: boolean; @@ -128,11 +129,6 @@ export const ColorPicker: React.FunctionComponent = ( props.eyedropperBackdropSelector ?? defaultEyedropperBackdropSelector; const hasNativeEyedropper = !!getEyeDropperConstructor(); - // Use a content-based key so we detect when the color content changes, - // even if the object reference is the same (e.g., eyedropper mutations). - const currentColorKey = - props.currentColor.colors.join("|") + "|" + props.currentColor.opacity; - // 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(() => { @@ -148,17 +144,27 @@ export const ColorPicker: React.FunctionComponent = ( "EditTab.DirectFormatting.labelForDefaultColor", ); - const changeColor = (swatchColor: IColorInfo) => { - const clonedColor: IColorInfo = { - ...swatchColor, - colors: [...swatchColor.colors], + 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). @@ -167,6 +173,11 @@ 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; @@ -181,7 +192,7 @@ export const ColorPicker: React.FunctionComponent = ( colors: [colorOnly], opacity: newOpacity, }; - changeColor(newColor); + changeColor(newColor, { complete: true }); }; const getColorInfoFromColorResult = ( @@ -232,6 +243,7 @@ export const ColorPicker: React.FunctionComponent = ( if (result?.sRGBHex) { changeColor( getColorInfoFromSpecialNameOrColorString(result.sRGBHex), + { complete: true }, ); } } catch { @@ -281,12 +293,13 @@ export const ColorPicker: React.FunctionComponent = ( overflow-x: hidden; `} > + {/* Keep the picker mounted during drags; remounting here breaks slider pointer capture. */}
void; + onChangeComplete?: (color: IColorInfo) => void; onDefaultClick?: () => void; onInputFocus: (input: HTMLElement) => void; includeDefault?: boolean; @@ -305,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)) { @@ -333,6 +335,14 @@ const ColorPickerDialog: React.FC = (props) => { props.onChange(clonedColor); }; + const handleOnChangeComplete = (color: IColorInfo) => { + const clonedColor: IColorInfo = { + ...color, + colors: [...color.colors], + }; + props.onChangeComplete?.(clonedColor); + }; + const dialogOpen = props.open === undefined ? open : props.open; // The MUI backdrop is rendered outside the dialog tree, so we use a body class @@ -405,6 +415,7 @@ const ColorPickerDialog: React.FC = (props) => { void; onChange?: (newColor: string) => void; onColorPickerVisibilityChanged?: (open: boolean) => void; @@ -523,6 +535,8 @@ 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, @@ -540,6 +554,28 @@ export const ColorDisplayButton: React.FC = ( [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); @@ -611,13 +647,12 @@ export const ColorDisplayButton: React.FC = ( palette={props.palette} initialColor={initialColorInfo} onInputFocus={() => {}} - onChange={(color: IColorInfo) => { - const newColor = getColorStringFromColorInfo(color); - setCurrentButtonColor(newColor); - if (props.onChange) { - props.onChange(newColor); - } - }} + 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 index 4c51210bb6a6..3f06ece6f45d 100644 --- a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts @@ -33,4 +33,63 @@ test.describe("ColorDisplayButton + ColorPickerDialog", () => { await dialog.locator(".swatch-row .color-swatch").first().click(); await expect(hexInput).not.toHaveValue("#111111"); }); + + 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 index a2abdd7da088..9bcf7368fd23 100644 --- a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx @@ -1,18 +1,40 @@ import * as React from "react"; +import { useState } from "react"; import { ColorDisplayButton, DialogResult } from "../colorPickerDialog"; import { BloomPalette } from "../bloomPalette"; -export const ColorDisplayButtonTestHarness: React.FunctionComponent = () => { +export const ColorDisplayButtonTestHarness: React.FunctionComponent<{ + initialColor?: string; + deferOnChangeUntilComplete?: boolean; +}> = (props) => { + const [changeCount, setChangeCount] = useState(0); + const [lastChangedColor, setLastChangedColor] = useState(""); + const [closeResult, setCloseResult] = useState(""); + return (
+
{changeCount}
+
{lastChangedColor}
+
{closeResult}
{}} + deferOnChangeUntilComplete={ + props.deferOnChangeUntilComplete ?? false + } + onChange={(newColor: string) => { + 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 index aa92c5b338e1..5af52014ad76 100644 --- a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts @@ -11,10 +11,10 @@ test.describe("ColorPicker", () => { ); const hexInput = page.locator('input[type="text"]'); - await expect(hexInput).toHaveValue("#111111"); + await expect(hexInput).toHaveValue("#111111FF"); await page.locator(".swatch-row .color-swatch").first().click(); - await expect(hexInput).toHaveValue("#AA0000"); + await expect(hexInput).toHaveValue("#AA0000FF"); }); test("eyedropper (native) updates hex input", async ({ page }) => { @@ -40,10 +40,10 @@ test.describe("ColorPicker", () => { ); const hexInput = page.locator('input[type="text"]'); - await expect(hexInput).toHaveValue("#111111"); + await expect(hexInput).toHaveValue("#111111FF"); await page.locator('button[title="Sample Color"]').click(); - await expect(hexInput).toHaveValue("#00AA00"); + await expect(hexInput).toHaveValue("#00AA00FF"); }); test("external currentColor change updates hex input", async ({ page }) => { @@ -55,9 +55,51 @@ test.describe("ColorPicker", () => { ); const hexInput = page.locator('input[type="text"]'); - await expect(hexInput).toHaveValue("#111111"); + await expect(hexInput).toHaveValue("#111111FF"); await page.getByTestId("simulate-external-color").click(); - await expect(hexInput).toHaveValue("#123456"); + 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); }); }); From c6a2da282c101a4d99054ef7edef585e8e6b70f1 Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 12 Mar 2026 16:15:26 -0600 Subject: [PATCH 15/17] Show transparency preview in color buttons --- .../color-picking/colorPickerDialog.tsx | 68 +++++++++++++++---- .../colorDisplayButton.uitest.ts | 29 ++++++++ .../colorDisplayButtonTestHarness.tsx | 3 +- 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx index a511a29f579e..139f292b1e3b 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx @@ -601,29 +601,71 @@ export const ColorDisplayButton: React.FC = (
{ - if (props.disabled) return; - if (onColorPickerVisibilityChanged) { - onColorPickerVisibilityChanged(true); - } - setColorAtDialogOpen(props.initialColor); - setDialogOpen(true); - }} - /> + > +
+
{ + if (props.disabled) return; + if (onColorPickerVisibilityChanged) { + onColorPickerVisibilityChanged(true); + } + setColorAtDialogOpen(props.initialColor); + setDialogOpen(true); + }} + /> +
{ 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, }) => { diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx index 9bcf7368fd23..ab8e9bd17f52 100644 --- a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx @@ -5,6 +5,7 @@ import { BloomPalette } from "../bloomPalette"; export const ColorDisplayButtonTestHarness: React.FunctionComponent<{ initialColor?: string; + transparency?: boolean; deferOnChangeUntilComplete?: boolean; }> = (props) => { const [changeCount, setChangeCount] = useState(0); @@ -20,7 +21,7 @@ export const ColorDisplayButtonTestHarness: React.FunctionComponent<{ disabled={false} initialColor={props.initialColor ?? "#111111"} localizedTitle="Background Color" - transparency={false} + transparency={props.transparency ?? false} palette={BloomPalette.CoverBackground} width={75} deferOnChangeUntilComplete={ From 95a9857beb548a8aee2e9e8a3137c23e17a9d02d Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 12 Mar 2026 16:15:57 -0600 Subject: [PATCH 16/17] Remove invalid props spread from collections pane --- .../collectionsTabBookPane/CollectionsTabBookPane.tsx | 1 - 1 file changed, 1 deletion(-) 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 >
Date: Thu, 12 Mar 2026 16:16:03 -0600 Subject: [PATCH 17/17] Remove unused page number localization entries --- .../localization/en/BloomMediumPriority.xlf | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index 7830bba16954..2a540cf4dd93 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -757,11 +757,6 @@ ID: BookAndPageSettings.Colors Label for the page-level Colors page within the combined Book and Page Settings dialog. - - Page Number - ID: BookAndPageSettings.PageNumberGroup - Group label for the page-level page number color settings in the combined Book and Page Settings dialog. - Page Settings ID: PageSettings.Title @@ -772,21 +767,6 @@ ID: PageSettings.OpenTooltip Tooltip shown when hovering over the Page Settings button above a custom page. - - Outline Color - ID: PageSettings.OutlineColor - Label for the page-level page number outline color setting. - - - Use an outline color when the page number needs more contrast against the page. - ID: PageSettings.PageNumberOutlineColor.Description - Help text shown below the page-level page number outline color setting in the combined Book and Page Settings dialog. - - - 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. - ID: PageSettings.PageNumberBackgroundColor.Description - Help text shown below the page-level page number background color setting in the combined Book and Page Settings dialog. - Max Image Size BookSettings.eBook.Image.MaxResolution