diff --git a/.github/prompts/bloom-test-CURRENTPAGE.prompt.md b/.github/prompts/bloom-test-CURRENTPAGE.prompt.md new file mode 100644 index 000000000000..231bfff7cb13 --- /dev/null +++ b/.github/prompts/bloom-test-CURRENTPAGE.prompt.md @@ -0,0 +1,4 @@ +--- +description: use browser tools to test and debug +--- +The backend should already be running and serving a page at http://localhost:/bloom/CURRENTPAGE. is usually 8089. If i include a port number, use that, otherwise use 8089. You may use chrome-devtools-mcp, playwright-mcp, or other browser management tools. If you can't find any, use askQuestions tool to ask me to enable something for you to use. diff --git a/.github/skills/reviewable-thread-replies/SKILL.md b/.github/skills/reviewable-thread-replies/SKILL.md new file mode 100644 index 000000000000..466157a088c5 --- /dev/null +++ b/.github/skills/reviewable-thread-replies/SKILL.md @@ -0,0 +1,93 @@ +--- +name: reviewable-thread-replies +description: 'Reply to GitHub and Reviewable PR discussion threads one-by-one. Use whenever the user asks you to respond to review comments with accurate in-thread replies and verification.' +argument-hint: 'Repo/PR and target comments to reply to (for example: BloomBooks/BloomDesktop#7557 + specific discussion links/IDs)' +note: it's not clear that this skill is adequately developed, it's not clear that it works. +--- + +# Reviewable Thread Replies + +## What This Skill Does +Posts in-thread replies on both: +- GitHub PR review comments (`discussion_r...`) +- Reviewable-only discussion anchors quoted in review bodies + +## When To Use +- The user asks you to respond to one or more PR comments. +- Some comments are directly replyable on GitHub, while others only exist as Reviewable anchors. +- You need one response per thread, posted in the right place. + +## Inputs +- figure out the PR using the gh cli +- Target links or IDs (GitHub `discussion_r...` or Reviewable `#-...` anchors), or enough context to discover them. +- Reply text supplied by user, or instruction to compose replies from thread context. + +## Required Reply Format +- Every posted reply must begin with `[]`. +- Do not prepend workflow labels (for example `Will do, TODO`). + +## Procedure +1. Collect and normalize targets. +- Build a list of target threads with: `target`, `context`, `response`. +- If response text is not provided, compose a concise response from the thread context. +- Separate items into: + - GitHub direct thread comments (have comment IDs / `discussion_r...`). + - Reviewable-only threads (anchor IDs like `-Oko...`). + +2. Post direct GitHub thread replies first. +- Use GitHub PR review comment reply API/tool for each direct comment ID. +- Post exactly one response per thread. +- Verify the new reply IDs/URLs are returned. + +3. Open Reviewable, give the user time to authenticate. +- Navigate to the PR in Reviewable. +- If the user session is not active, use Reviewable sign-in flow and confirm identity before posting. + +4. Reply to Reviewable-only threads one by one. +- For each discussion anchor: + - Navigate to the anchor. + - Find the thread reply input for that discussion. + - Post response text with the required `[]` prefix. + - Avoid adding status macros or extra prefixes. +- Wait for each post to render before moving to the next thread. + +5. Verification pass. +- Re-check every target thread and confirm the expected response appears. +- Confirm no target remains unreplied due to navigation/context loss. +- Confirm no accidental text prefixes were added. + +## Decision Points +- If target has GitHub comment ID: use GitHub API/tool reply path. +- If target exists only in Reviewable anchor: use browser automation path. +- If Reviewable shows sign-in or disabled reply controls: authenticate first, then retry. +- Never click `resolve`, `done`, or `acknowledge` controls and never change discussion resolution state. +- If reply input transitions into a temporary composer panel: + - Submit without modifying response text semantics. + - Keep the required `[]` prefix and avoid workflow labels. +- If posted text does not match intended response: correct immediately before continuing. + +## Quality Criteria +- Exactly one intended response posted per target thread. +- Responses are correct for thread context and begin with `[]`. +- No unwanted prefixes like `Will do, TODO`. +- No unresolved posting errors left undocumented. +- Final status includes: posted targets and skipped/failed targets. + +## Guardrails +- Do not post broad summary comments when thread-level replies were requested. +- Do not resolve, acknowledge, dismiss, or otherwise change PR discussion status; leave resolution actions to humans. +- Do not rely on internal/private page APIs for mutation unless officially supported and permission-safe. +- Do not assume draft state implies publication; verify thread-visible posted output. +- Do not continue after repeated auth/permission failures without reporting the blocker. + +## Quick Command Hints +- List PR review comments: +```bash + gh api repos///pulls//comments --paginate +``` + +- List PR reviews (to inspect review-body quoted discussions): +```bash + gh api repos///pulls//reviews --paginate +``` + diff --git a/DistFiles/localization/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 e772c774d38c..ae172e88cd97 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..5dcfc6a7635a 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 - Configuración del libro - BookSettings.Title + + Book and Page Settings + Configuración del libro + 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..24edbf7f4234 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -0,0 +1,460 @@ +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; +} + +// Stuff we get from the book/settings/overrides api. +// The branding and xmatter objects contain the corresponding settings, +// using the same keys as appearance.json. Currently the values are all +// booleans. +interface IOverrideInformation { + branding: object; + xmatter: object; + brandingName: string; + xmatterName: string; +} + +export const BookAndPageSettingsDialog: React.FunctionComponent<{ + initiallySelectedPageKey?: string; +}> = (props) => { + const { closeDialog, propsForBloomDialog } = useSetupBloomDialog({ + initiallyOpen: true, + dialogFrameProvidedExternally: false, + }); + + const appearanceUIOptions: IAppearanceUIOptions = + useApiObject( + "book/settings/appearanceUIOptions", + { + themeNames: [], + }, + ); + + // If we pass a new default value to useApiObject on every render, it will query the host + // every time and then set the result, which triggers a new render, making an infinite loop. + const defaultOverrides = React.useMemo(() => { + return { + xmatter: {}, + branding: {}, + xmatterName: "", + brandingName: "", + }; + }, []); + + const overrideInformation: IOverrideInformation | undefined = + useApiObject( + "book/settings/overrides", + defaultOverrides, + ); + + const [pageSizeSupportsFullBleed] = useApiBoolean( + "book/settings/pageSizeSupportsFullBleed", + true, + ); + + const xmatterLockedBy = useL10n( + "Locked by {0} Front/Back matter", + "BookSettings.LockedByXMatter", + "", + overrideInformation?.xmatterName, + ); + + const brandingLockedBy = useL10n( + "Locked by {0} Branding", + "BookSettings.LockedByBranding", + "", + overrideInformation?.brandingName, + ); + + // This is a helper function to make it easier to pass the override information + function getAdditionalProps(subPath: string): { + path: string; + overrideValue: T; + overrideDescription?: string; + } { + // some properties will be overridden by branding and/or xmatter + const xmatterOverride: T | undefined = + overrideInformation?.xmatter?.[subPath]; + const brandingOverride = overrideInformation?.branding?.[subPath]; + const override = xmatterOverride ?? brandingOverride; + // nb: xmatterOverride can be boolean, hence the need to spell out !==undefined + let description = + xmatterOverride !== undefined ? xmatterLockedBy : undefined; + if (!description) { + // xmatter wins if both are present + description = + brandingOverride !== undefined ? brandingLockedBy : undefined; + } + // make a an object that can be spread as props in any of the Configr controls + return { + path: "appearance." + subPath, + overrideValue: override as T, + // if we're disabling all appearance controls (e.g. because we're in legacy), don't list a second reason for this overload + overrideDescription: appearanceDisabled ? "" : description, + }; + } + + const [settingsString] = useApiStringState( + "book/settings", + "{}", + () => propsForBloomDialog.open, + ); + + const [settings, setSettings] = React.useState( + undefined, + ); + + const [pageSettings, setPageSettings] = React.useState< + IPageSettings | undefined + >(undefined); + + const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< + ConfigrValues | undefined + >(undefined); + const dialogRef = React.useRef(null); + + const setDialogVisibleWhileColorPickerOpen = React.useCallback( + (open: boolean) => { + const dialogRoot = dialogRef.current?.closest(".MuiDialog-root"); + if (!(dialogRoot instanceof HTMLElement)) { + return; + } + if (open) { + dialogRoot.style.visibility = "hidden"; + dialogRoot.style.pointerEvents = "none"; + } else { + dialogRoot.style.visibility = ""; + dialogRoot.style.pointerEvents = ""; + } + }, + [], + ); + + const removePageSettingsFromConfigrSettings = ( + settingsValue: ConfigrValues, + ): IBookSettings => { + const settingsWithoutPage = { + ...settingsValue, + } as Record; + delete settingsWithoutPage["page"]; + return settingsWithoutPage as IBookSettings; + }; + + const configrInitialValues: ConfigrValues | undefined = + React.useMemo(() => { + if (!settings || !pageSettings) { + return undefined; + } + + return { + ...settings, + page: pageSettings.page, + } as unknown as ConfigrValues; + }, [settings, pageSettings]); + + const [appearanceDisabled, setAppearanceDisabled] = React.useState(false); + + // We use state here to allow the dialog UI to update without permanently changing the settings + // and getting notified of those changes. The changes are persisted when the user clicks OK. + const [theme, setTheme] = React.useState(""); + const [firstPossiblyLegacyCss, setFirstPossiblyLegacyCss] = + React.useState(""); + const [migratedTheme, setMigratedTheme] = React.useState(""); + + const initialPageAttributeSnapshot = React.useRef< + ElementAttributeSnapshot | undefined + >(undefined); + + React.useEffect(() => { + if (settingsString === "{}") { + return; // leave settings as undefined + } + if (typeof settingsString === "string") { + setSettings(JSON.parse(settingsString)); + } else { + setSettings(settingsString); + } + }, [settingsString]); + + React.useEffect(() => { + setPageSettings(getCurrentPageSettings()); + initialPageAttributeSnapshot.current = + ElementAttributeSnapshot.fromElement(getCurrentPageElement()); + }, []); + + React.useEffect(() => { + return () => { + setDialogVisibleWhileColorPickerOpen(false); + }; + }, [setDialogVisibleWhileColorPickerOpen]); + + React.useEffect(() => { + setFirstPossiblyLegacyCss( + appearanceUIOptions?.firstPossiblyLegacyCss ?? "", + ); + setMigratedTheme(appearanceUIOptions?.migratedTheme ?? ""); + }, [appearanceUIOptions]); + + const bookSettingsTitle = useL10n( + "Book and Page Settings", + "BookAndPageSettings.Title", + ); + + React.useEffect(() => { + if (settings?.appearance) { + const liveAppearance = + (settingsToReturnLater?.["appearance"] as + | IAppearanceSettings + | undefined) ?? settings.appearance; + // when we're in legacy, we're just going to disable all the appearance controls + setAppearanceDisabled( + liveAppearance?.cssThemeName === "legacy-5-6", + ); + setTheme(liveAppearance?.cssThemeName ?? ""); + } + }, [settings, settingsToReturnLater]); + + const deleteCustomBookStyles = () => { + post( + `book/settings/deleteCustomBookStyles?file=${firstPossiblyLegacyCss}`, + ); + setFirstPossiblyLegacyCss(""); + setMigratedTheme(""); + }; + + const tierAllowsFullPageCoverImage = + useGetFeatureStatus("fullPageCoverImage")?.enabled; + + const tierAllowsFullBleed = useGetFeatureStatus("PrintshopReady")?.enabled; + + const closeDialogAndClearOpenFlag = React.useCallback(() => { + isOpenAlready = false; + closeDialog(); + }, [closeDialog]); + + const cancelAndCloseDialog = React.useCallback(() => { + if (initialPageAttributeSnapshot.current) { + initialPageAttributeSnapshot.current.restoreToElement( + getCurrentPageElement(), + ); + } + closeDialogAndClearOpenFlag(); + }, [closeDialogAndClearOpenFlag]); + + function saveSettingsAndCloseDialog() { + const latestSettings = settingsToReturnLater; + if (latestSettings) { + applyPageSettings( + parsePageSettingsFromConfigrValue(latestSettings), + ); + + const settingsToPost = + removePageSettingsFromConfigrSettings(latestSettings); + // If nothing changed, we don't get any...and don't need to make this call. + postJson("book/settings", settingsToPost); + } + + closeDialogAndClearOpenFlag(); + // todo: how do we make the pageThumbnailList reload? It's in a different browser, so + // we can't use a global. It listens to websocket, but we currently can only listen, + // we cannot send. + } + + const bookSettingsArea = useBookSettingsAreaDefinition({ + appearanceDisabled, + tierAllowsFullPageCoverImage, + tierAllowsFullBleed, + pageSizeSupportsFullBleed, + settings, + settingsToReturnLater, + getAdditionalProps, + firstPossiblyLegacyCss, + theme, + migratedTheme, + deleteCustomBookStyles, + saveSettingsAndCloseDialog, + onColorPickerVisibilityChanged: setDialogVisibleWhileColorPickerOpen, + themeNames: appearanceUIOptions.themeNames, + }); + + const pageSettingsArea = usePageSettingsAreaDefinition({ + onColorPickerVisibilityChanged: setDialogVisibleWhileColorPickerOpen, + }); + + return ( + cancelAndCloseDialog()} + onCancel={() => cancelAndCloseDialog()} + draggable={false} + maxWidth={false} + > + + + {configrInitialValues && ( + { + const parsedPageSettings = + parsePageSettingsFromConfigrValue(s); + const isInitialConfigrEcho = + !settingsToReturnLater && + !!pageSettings && + arePageSettingsEquivalent( + parsedPageSettings, + pageSettings, + ); + + // Config-r may call onChange while rendering, so defer state updates. + window.setTimeout(() => { + setSettingsToReturnLater(s); + }, 0); + + if (isInitialConfigrEcho) { + return; + } + + applyPageSettings(parsedPageSettings); + }} + initiallySelectedTopLevelPageKey={ + props.initiallySelectedPageKey + } + > + + {bookSettingsArea.pages} + + + {pageSettingsArea.pages} + + + )} + + + + + + + ); +}; + +export function showBookSettingsDialog(initiallySelectedPageKey?: string) { + // once Bloom's tab bar is also in react, it won't be possible + // to open another copy of this without closing it first, but + // for now, we need to prevent that. + if (!isOpenAlready) { + isOpenAlready = true; + ShowEditViewDialog( + , + ); + } +} diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx new file mode 100644 index 000000000000..80034779437f --- /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: object | undefined; + getAdditionalProps: (subPath: string) => { + path: string; + overrideValue: T; + overrideDescription?: string; + }; + firstPossiblyLegacyCss: string; + theme: string; + migratedTheme: string; + deleteCustomBookStyles: () => void; + saveSettingsAndCloseDialog: () => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; + themeNames: Array<{ label: string; value: string }>; +}; + +export type IConfigrAreaDefinition = { + label: string; + pageKey: string; + content: string; + pages: React.ReactElement[]; +}; + +export const useBookSettingsAreaDefinition = ( + props: BookSettingsAreaProps, +): IConfigrAreaDefinition => { + const bookAreaLabel = useL10n("Book", "BookAndPageSettings.BookArea"); + const bookAreaDescription = useL10n( + "Book settings apply to all of the pages of the current book.", + "BookAndPageSettings.BookArea.Description", + ); + + const coverLabel = useL10n("Cover", "BookSettings.CoverGroupLabel"); + const contentPagesLabel = useL10n( + "Content Pages", + "BookSettings.ContentPagesGroupLabel", + ); + const printPublishingLabel = useL10n( + "Print Publishing", + "BookSettings.PrintPublishingGroupLabel", + ); + const languagesToShowNormalSubgroupLabel = useL10n( + "Languages to show in normal text boxes", + "BookSettings.NormalTextBoxLangsLabel", + "", + ); + const themeLabel = useL10n("Page Theme", "BookSettings.PageThemeLabel", ""); + const themeDescription = useL10n( + "", // will be translated or the English will come from the xliff + "BookSettings.Theme.Description", + ); + + const coverBackgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + + const whatToShowOnCoverLabel = useL10n( + "Front Cover", + "BookSettings.WhatToShowOnCover", + ); + + const showLanguageNameLabel = useL10n( + "Show Language Name", + "BookSettings.ShowLanguageName", + ); + const showTopicLabel = useL10n("Show Topic", "BookSettings.ShowTopic"); + const showCreditsLabel = useL10n( + "Show Credits", + "BookSettings.ShowCredits", + ); + const pageNumbersLabel = useL10n( + "Page Numbers", + "BookSettings.PageNumbers", + ); + const pageNumberLocationNote = useL10n( + "Note: some Page Themes may not know how to change the location of the Page Number.", + "BookSettings.PageNumberLocationNote", + ); + const pageNumberPositionAutomaticLabel = useL10n( + "(Automatic)", + "BookSettings.PageNumbers.Automatic", + ); + const pageNumberPositionLeftLabel = useL10n( + "Left", + "BookSettings.PageNumbers.Left", + ); + const pageNumberPositionCenterLabel = useL10n( + "Center", + "BookSettings.PageNumbers.Center", + ); + const pageNumberPositionRightLabel = useL10n( + "Right", + "BookSettings.PageNumbers.Right", + ); + const pageNumberPositionHiddenLabel = useL10n( + "Hidden", + "BookSettings.PageNumbers.Hidden", + ); + + const resolutionLabel = useL10n("Resolution", "BookSettings.Resolution"); + const bloomPubLabel = useL10n("eBooks", "PublishTab.bloomPUBButton"); + + const advancedLayoutLabel = useL10n( + "Advanced Layout", + "BookSettings.AdvancedLayoutLabel", + ); + const textPaddingLabel = useL10n( + "Text Padding", + "BookSettings.TopLevelTextPaddingLabel", + ); + const textPaddingDescription = useL10n( + "Smart spacing around text boxes. Works well for simple pages, but may not suit custom layouts.", + "BookSettings.TopLevelTextPadding.Description", + ); + const textPaddingDefaultLabel = useL10n( + "Default (set by Theme)", + "BookSettings.TopLevelTextPadding.DefaultLabel", + ); + const textPadding1emLabel = useL10n( + "1 em (font size)", + "BookSettings.TopLevelTextPadding.1emLabel", + ); + + const gutterLabel = useL10n("Page Gutter", "BookSettings.Gutter.Label"); + const gutterDescription = useL10n( + "Extra space between pages near the book spine. Increase this for books with many pages to ensure text isn't lost in the binding. This gap is applied to each side of the spine.", + "BookSettings.Gutter.Description", + ); + const gutterDefaultLabel = useL10n( + "Default (set by Theme)", + "BookSettings.Gutter.DefaultLabel", + ); + + const coverIsImageLabel = useL10n( + "Fill the front cover with a single image", + "BookSettings.CoverIsImage", + ); + const coverIsImageDescription = useL10n( + "Replace the front cover content with a single full-bleed image. See [Full Page Cover Images](https://docs.bloomlibrary.org/full-page-cover-images) for information on sizing your image to fit.", + "BookSettings.CoverIsImage.Description.V2", + ); + + const fullBleedLabel = useL10n( + "Use full bleed page layout", + "BookSettings.FullBleed", + ); + const fullBleedDescription = useL10n( + "Enable full bleed layout for printing. This turns on the [Print Bleed](https://en.wikipedia.org/wiki/Bleed_%28printing%29) indicators on paper layouts. See [Full Bleed Layout](https://docs.bloomlibrary.org/full-bleed) for more information.", + "BookSettings.FullBleed.Description", + ); + + const coverColorPickerControl = React.useCallback( + (coverColorProps: { + value: string; + disabled: boolean; + onChange: (value: string) => void; + }) => { + return ( + + ); + }, + [props.onColorPickerVisibilityChanged], + ); + + return { + label: bookAreaLabel, + pageKey: "bookArea", + content: bookAreaDescription, + pages: [ + + {props.appearanceDisabled && ( + + +
+ The selected page theme does not support the + following settings. +
+
+
+ )} + +
+ ( + `coverIsImage`, + )} + disabled={ + props.appearanceDisabled || + !props.tierAllowsFullPageCoverImage + } + /> +
+ +
+
+ + + ( + `cover-languageName-show`, + )} + /> + ( + `cover-topic-show`, + )} + /> + ( + `cover-creditsRow-show`, + )} + /> +
+ + ( + `cover-background-color`, + )} + /> + +
, + + { + // This group of four possible messages...sometimes none of them shows, so there are five options... + // is very similar to the one in BookInfoIndicator.tsx. If you change one, you may need to change the other. + // In particular, the logic for which to show and the text of the messages should be kept in sync. + // I'm not seeing a clean way to reuse the logic. Some sort of higher-order component might work, + // but I don't think the logic is complex enough to be worth it, when only used in two places. + } + {props.firstPossiblyLegacyCss.length > 0 && + props.theme === "legacy-5-6" && ( + + + + + + )} + {props.firstPossiblyLegacyCss === "customBookStyles.css" && + props.theme !== "legacy-5-6" && ( + + +
+ {props.migratedTheme ? ( + + ) : ( + + )} +
+ props.deleteCustomBookStyles() + } + > + +
+ Delete{" "} + {props.firstPossiblyLegacyCss} +
+
+
+
+
+ )} + {props.firstPossiblyLegacyCss.length > 0 && + props.firstPossiblyLegacyCss !== "customBookStyles.css" && + props.theme !== "legacy-5-6" && ( + + + + + + )} + + {/* Wrapping these two in a div prevents Config-R from sticking a divider between them */} +
+ { + return { + label: x.label, + value: x.value, + }; + })} + description={themeDescription} + /> + {props.appearanceDisabled && ( + +
+ The selected page theme does not support the + following settings. +
+
+ )} +
+ ( + `pageNumber-position`, + )} + options={[ + { + label: pageNumberPositionAutomaticLabel, + value: PageNumberPosition.Automatic, + }, + { + label: pageNumberPositionLeftLabel, + value: PageNumberPosition.Left, + }, + { + label: pageNumberPositionCenterLabel, + value: PageNumberPosition.Center, + }, + { + label: pageNumberPositionRightLabel, + value: PageNumberPosition.Right, + }, + { + label: "--", + value: "--", + }, + { + label: pageNumberPositionHiddenLabel, + value: PageNumberPosition.Hidden, + }, + ]} + description={pageNumberLocationNote} + /> +
+ + + + + ( + `topLevel-text-padding`, + )} + /> + (`page-gutter`)} + /> + +
, + + +
+ (`fullBleed`)} + disabled={ + !props.tierAllowsFullBleed || + !props.pageSizeSupportsFullBleed + } + /> +
+ +
+
+
+
, + + {/* note that this is used for bloomPUB and ePUB, but we don't have separate settings so we're putting them in bloomPUB and leaving it to c# code to use it for ePUB as well. */} + + + + , + + + + +
+

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

+

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

+
+
+ +
+
+
, + ], + }; +}; + +const BloomResolutionSlider: React.FunctionComponent< + React.PropsWithChildren<{ + path: string; + label: string; + }> +> = (props) => { + return ( +
+ + control={BloomResolutionSliderInner} + {...props} + > +
+ Bloom reduces images to a maximum size to make books easier to + view over poor internet connections and take up less space on + phones. +
+
+ ); +}; + +const BloomResolutionSliderInner: React.FunctionComponent<{ + value: Resolution; + onChange: (value: Resolution) => void; +}> = (props) => { + const sizes = [ + { l: "Small", w: 600, h: 600 }, + { l: "HD", w: 1280, h: 720 }, + { l: "Full HD", w: 1920, h: 1080 }, + { l: "4K", w: 3840, h: 2160 }, + ]; + let currentIndex = sizes.findIndex((x) => x.w === props.value.maxWidth); + if (currentIndex === -1) { + currentIndex = 1; // See BL-12803. + } + const current = sizes[currentIndex]; + const currentLabel = useL10n( + current.l, + `BookSettings.eBook.Image.MaxResolution.${current.l}`, + ); + + return ( + +
+ {`${currentLabel}`} + { + return `${current.w}x${current.h}`; + }} + onChange={(e, value) => { + props.onChange({ + maxWidth: sizes[value as number].w, + maxHeight: sizes[value as number].h, + }); + }} + valueLabelDisplay="auto" + > +
+
+ ); +}; + +const CoverColorPickerForConfigr: React.FunctionComponent<{ + value: string; + disabled: boolean; + onChange: (value: string) => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const coverBackgroundColorLabel = useL10n( + "Background Color", + "Common.BackgroundColor", + ); + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + /> + ); +}; + +export const MessageUsingLegacyThemeWithIncompatibleCss: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( + + The {0} stylesheet of this book is incompatible with modern themes. + Bloom is using it because the book is using the Legacy-5-6 theme. + Click [here] for more information. + + ); +}; + +export const MessageUsingMigratedThemeInsteadOfIncompatibleCss: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( +
+ Bloom found a known version of {props.fileName} in this book and + replaced it with a modern theme. You can delete it unless you still + need to publish the book from an earlier version of Bloom. +
+ ); +}; + +export const MessageIgnoringIncompatibleCssCanDelete: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( + + The + {props.fileName} stylesheet of this book is incompatible with modern + themes. Bloom is currently ignoring it. If you don't need those + customizations any more, you can delete your + {props.fileName}. Click [here] for more information. + + ); +}; + +export const MessageIgnoringIncompatibleCss: React.FunctionComponent<{ + fileName: string; + className?: string; +}> = (props) => { + return ( + + The {props.fileName} stylesheet of this book is incompatible with + modern themes. Bloom is currently ignoring it. Click [here] for more + information. + + ); +}; diff --git a/src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx similarity index 93% rename from src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx rename to src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx index 3dcffdce8ed1..655fea9b80da 100644 --- a/src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/FieldVisibilityGroup.tsx @@ -20,7 +20,7 @@ export const FieldVisibilityGroup: React.FunctionComponent<{ labelFrame: string; labelFrameL10nKey: string; settings: object | undefined; - settingsToReturnLater: string | object | undefined; + settingsToReturnLater: object | undefined; disabled: boolean; L1MustBeTurnedOn?: boolean; @@ -83,13 +83,7 @@ export const FieldVisibilityGroup: React.FunctionComponent<{ const [showL1, showL2, showL3, numberShowing] = useMemo(() => { let appearance = props.settings?.["appearance"]; if (props.settingsToReturnLater) { - // although we originally declared it a string, Config-R may return a JSON string or an object - if (typeof props.settingsToReturnLater === "string") { - const parsedSettings = JSON.parse(props.settingsToReturnLater); - appearance = parsedSettings["appearance"]; - } else { - appearance = props.settingsToReturnLater["appearance"]; - } + appearance = props.settingsToReturnLater["appearance"]; } if (!appearance) { // This is a bit arbitrary. It should only apply during early renders. diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx new file mode 100644 index 000000000000..c3706427cb04 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/PageSettingsConfigrPages.tsx @@ -0,0 +1,407 @@ +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 => { + if (typeof value !== "object" || !value) { + throw new Error("Page settings are not an object"); + } + const parsedRecord = value as Record; + const pageValues = parsedRecord["page"]; + + if (typeof pageValues !== "object" || !pageValues) { + throw new Error("Page settings are missing the page object"); + } + + const pageRecord = pageValues as Record; + + const backgroundColor = pageRecord["backgroundColor"]; + const pageNumberColor = pageRecord["pageNumberColor"]; + const 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) + ); +}; + +type IConfigrColorPickerControlProps = { + value: string; + disabled?: boolean; + onChange: (value: string) => void; +}; + +const ConfigrColorPickerControl: React.FunctionComponent< + IConfigrColorPickerControlProps & { + localizedTitle: string; + transparency: boolean; + palette: BloomPalette; + emptyValueDisplayColor?: string; + onColorPickerVisibilityChanged?: (open: boolean) => void; + } +> = (props) => { + const initialColor = props.value || props.emptyValueDisplayColor; + + return ( + { + if (dialogResult === DialogResult.OK) props.onChange(newColor); + }} + onChange={(newColor) => props.onChange(newColor)} + /> + ); +}; + +const PageSettingsConfigrColorInput: React.FunctionComponent<{ + label: string; + path: string; + localizedTitle: string; + transparency: boolean; + palette: BloomPalette; + emptyValueDisplayColor?: string; + disabled?: boolean; + onColorPickerVisibilityChanged?: (open: boolean) => void; +}> = (props) => { + const colorControl = React.useCallback( + (pickerProps: IConfigrColorPickerControlProps) => ( + + ), + [ + props.emptyValueDisplayColor, + props.localizedTitle, + props.onColorPickerVisibilityChanged, + props.palette, + props.transparency, + ], + ); + + return ( + + ); +}; + +const 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", + ); + + 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/BookSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx deleted file mode 100644 index c9975a998edd..000000000000 --- a/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx +++ /dev/null @@ -1,1097 +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 "../editViewFrame"; -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"; - -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( - 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; - - 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 && - theme === "legacy-5-6" && ( - - - - - - )} - {firstPossiblyLegacyCss === - "customBookStyles.css" && - theme !== "legacy-5-6" && ( - - -
- {migratedTheme ? ( - - ) : ( - - )} -
- deleteCustomBookStyles() - } - > - -
- Delete{" "} - {firstPossiblyLegacyCss} -
-
-
-
-
- )} - {firstPossiblyLegacyCss.length > 0 && - firstPossiblyLegacyCss !== - "customBookStyles.css" && - 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} - /> - {appearanceDisabled && ( - -
- The selected page theme does not - support the following settings. -
-
- )} -
- ( - `pageNumber-position`, - )} - options={[ - { - label: pageNumberPositionAutomaticLabel, - value: PageNumberPosition.Automatic, - }, - { - label: pageNumberPositionLeftLabel, - value: PageNumberPosition.Left, - }, - { - label: pageNumberPositionCenterLabel, - value: PageNumberPosition.Center, - }, - { - label: pageNumberPositionRightLabel, - value: PageNumberPosition.Right, - }, - { - label: "--", - value: "--", - }, - { - label: pageNumberPositionHiddenLabel, - value: PageNumberPosition.Hidden, - }, - ]} - description={pageNumberLocationNote} - /> -
- - - - - ( - `topLevel-text-padding`, - )} - /> - ( - `page-gutter`, - )} - /> - -
- - -
- ( - `fullBleed`, - )} - disabled={ - !tierAllowsFullBleed || - !pageSizeSupportsFullBleed - } - /> -
- -
-
-
-
- - {/* note that this is used for bloomPUB and ePUB, but we don't have separate settings so we're putting them in bloomPUB and leaving it to c# code to use it for ePUB as well. */} - - - - - - - - -
-

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

-

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

-
-
- -
-
-
-
- )} -
- - - - -
- ); -}; - -type Resolution = { - maxWidth: number; - maxHeight: number; -}; - -const BloomResolutionSlider: React.FunctionComponent< - React.PropsWithChildren<{ - path: string; - label: string; - }> -> = (props) => { - return ( -
- - control={BloomResolutionSliderInner} - {...props} - > -
- Bloom reduces images to a maximum size to make books easier to - view over poor internet connections and take up less space on - phones. -
-
- ); -}; - -const BloomResolutionSliderInner: React.FunctionComponent<{ - value: Resolution; - onChange: (value: Resolution) => void; -}> = (props) => { - const sizes = [ - { l: "Small", w: 600, h: 600 }, - { l: "HD", w: 1280, h: 720 }, - { l: "Full HD", w: 1920, h: 1080 }, - { l: "4K", w: 3840, h: 2160 }, - ]; - let currentIndex = sizes.findIndex((x) => x.w === props.value.maxWidth); - if (currentIndex === -1) { - currentIndex = 1; // See BL-12803. - } - const current = sizes[currentIndex]; - const currentLabel = useL10n( - current.l, - `BookSettings.eBook.Image.MaxResolution.${current.l}`, - ); - - return ( - -
- {`${currentLabel}`} - { - return `${current.w}x${current.h}`; - }} - onChange={(e, value) => { - props.onChange({ - maxWidth: sizes[value as number].w, - maxHeight: sizes[value as number].h, - }); - }} - valueLabelDisplay="auto" - > -
-
- ); -}; - -export function showBookSettingsDialog(initiallySelectedGroupIndex?: number) { - // once Bloom's tab bar is also in react, it won't be possible - // to open another copy of this without closing it first, but - // for now, we need to prevent that. - if (!isOpenAlready) { - isOpenAlready = true; - ShowEditViewDialog( - , - ); - } -} - -export const MessageUsingLegacyThemeWithIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The {0} stylesheet of this book is incompatible with modern themes. - Bloom is using it because the book is using the Legacy-5-6 theme. - Click [here] for more information. - - ); -}; - -export const MessageUsingMigratedThemeInsteadOfIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( -
- Bloom found a known version of {props.fileName} in this book and - replaced it with a modern theme. You can delete it unless you still - need to publish the book from an earlier version of Bloom. -
- ); -}; - -export const MessageIgnoringIncompatibleCssCanDelete: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The - {props.fileName} stylesheet of this book is incompatible with modern - themes. Bloom is currently ignoring it. If you don't need those - customizations any more, you can delete your - {props.fileName}. Click [here] for more information. - - ); -}; -export const MessageIgnoringIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The {props.fileName} stylesheet of this book is incompatible with - modern themes. Bloom is currently ignoring it. Click [here] for more - information. - - ); -}; - -const ColorPickerForConfigr: React.FunctionComponent<{ - value: string; - disabled: boolean; - onChange: (value: string) => void; -}> = (props) => { - const coverBackgroundColorLabel = useL10n( - "Background Color", - "Common.BackgroundColor", - ); - - return ( - { - if (dialogResult === DialogResult.OK) props.onChange(newColor); - }} - /> - ); -}; diff --git a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less index 308d3e85bd86..c255d42e0b7a 100644 --- a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less +++ b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less @@ -163,9 +163,10 @@ top: @ToggleVerticalOffset; width: 100%; display: flex; - justify-content: end; + justify-content: space-between; box-sizing: border-box; } + .origami-toggle { cursor: pointer; margin-right: 19px; @@ -178,6 +179,39 @@ display: inline; } } +.page-settings-button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + width: auto; + height: 24px; + padding: 0 4px; + margin-right: 8px; + border: none; + background-color: transparent; + cursor: pointer; + color: @bloom-purple; + white-space: nowrap; + font-size: 12px; + line-height: 1; + + &:hover { + opacity: 0.8; + } + + svg { + width: 20px; + height: 20px; + flex-shrink: 0; + } + + .page-settings-button-label { + font-size: 11px; + line-height: 1; + white-space: nowrap; + } +} // here follows the inner workings of the toggle .onoffswitch { diff --git a/src/BloomBrowserUI/bookEdit/editViewFrame.ts b/src/BloomBrowserUI/bookEdit/editViewFrame.ts index 17715bec0877..c6f2b3b098a0 100644 --- a/src/BloomBrowserUI/bookEdit/editViewFrame.ts +++ b/src/BloomBrowserUI/bookEdit/editViewFrame.ts @@ -54,7 +54,7 @@ import { showPageChooserDialog } from "../pageChooser/PageChooserDialog"; export { showPageChooserDialog }; import "../lib/errorHandler"; -import { showBookSettingsDialog } from "./bookSettings/BookSettingsDialog"; +import { showBookSettingsDialog } from "./bookAndPageSettings/BookAndPageSettingsDialog"; export { showBookSettingsDialog }; import { showRegistrationDialogForEditTab } from "../react_components/registration/registrationDialog"; export { showRegistrationDialogForEditTab as showRegistrationDialog }; @@ -262,9 +262,13 @@ export function showEditViewTopicChooserDialog() { showTopicChooserDialog(); } export function showEditViewBookSettingsDialog( - initiallySelectedGroupIndex?: number, + initiallySelectedPageKey?: string, ) { - showBookSettingsDialog(initiallySelectedGroupIndex); + showBookSettingsDialog(initiallySelectedPageKey); +} + +export function showEditViewPageSettingsDialog() { + showBookSettingsDialog("colors"); } export function showAboutDialogInEditTab() { @@ -326,6 +330,7 @@ interface EditTabBundleApi { showCopyrightAndLicenseDialog: typeof showCopyrightAndLicenseDialog; showEditViewTopicChooserDialog: typeof showEditViewTopicChooserDialog; showEditViewBookSettingsDialog: typeof showEditViewBookSettingsDialog; + showEditViewPageSettingsDialog: typeof showEditViewPageSettingsDialog; showAboutDialogInEditTab: typeof showAboutDialogInEditTab; showRequiresSubscriptionDialog: typeof showRequiresSubscriptionDialog; showRegistrationDialogInEditTab: typeof showRegistrationDialogInEditTab; @@ -364,6 +369,7 @@ window.editTabBundle = { showCopyrightAndLicenseDialog, showEditViewTopicChooserDialog, showEditViewBookSettingsDialog, + showEditViewPageSettingsDialog, showAboutDialogInEditTab, showRequiresSubscriptionDialog, showRegistrationDialogInEditTab, diff --git a/src/BloomBrowserUI/bookEdit/js/origami.ts b/src/BloomBrowserUI/bookEdit/js/origami.ts index bca7c23a80ea..fd73a191701f 100644 --- a/src/BloomBrowserUI/bookEdit/js/origami.ts +++ b/src/BloomBrowserUI/bookEdit/js/origami.ts @@ -1,5 +1,3 @@ -// not yet: neither bloomEditing nor this is yet a module import {SetupImage} from './bloomEditing'; -/// import { SetupImage } from "./bloomImages"; import { kBloomCanvasClass } from "../toolbox/canvas/canvasElementUtils"; import "../../lib/split-pane/split-pane.js"; @@ -23,47 +21,91 @@ export function setupOrigami() { const isCanvasFeatureEnabled: boolean = canvasFeatureStatus?.enabled || false; const customPages = document.getElementsByClassName("customPage"); - if (customPages.length > 0) { - const width = customPages[0].clientWidth; - const origamiControl = getAbovePageControlContainer() - .append( - createTypeSelectors( - isWidgetFeatureEnabled, - isCanvasFeatureEnabled, - ), - ) - .append(createTextBoxIdentifier()); + const bloomPage = document.getElementsByClassName( + "bloom-page", + )[0] as HTMLElement | undefined; + const pageWidth = bloomPage?.clientWidth; + if (pageWidth !== undefined) { + const showOrigamiControls = customPages.length > 0; + const pageControlContainer = + getAbovePageControlContainer(showOrigamiControls); + + if (showOrigamiControls) { + pageControlContainer + .append( + createTypeSelectors( + isWidgetFeatureEnabled, + isCanvasFeatureEnabled, + ), + ) + .append(createTextBoxIdentifier()); + } + // The order of this is not important in most ways, since it is positioned absolutely. // However, we position the page label, also absolutely, in the same screen area, and // we want it on top of origami control, so that in template pages the user can edit it. // The page label is part of the page, so we want the page to come after the origami control. // (Could also do this with z-order, but I prefer to do what I can by ordering elements, // and save z-order for when it is really needed.) - $("#page-scaling-container").prepend(origamiControl); + $("#page-scaling-container").prepend(pageControlContainer); // The container width is set to 100% in the CSS, but we need to // limit it to no more than the actual width of the page. const toggleContainer = $(".above-page-control-container").get( 0, ); - toggleContainer.style.maxWidth = width + "px"; + if (toggleContainer instanceof HTMLElement) { + toggleContainer.style.maxWidth = pageWidth + "px"; + } } // I'm not clear why the rest of this needs to wait until we have // the two results, but none of the controls shows up if we leave it all // outside the bloomApi functions. $(".origami-toggle .onoffswitch").change(layoutToggleClickHandler); + $(".page-settings-button").click(pageSettingsButtonClickHandler); if ($(".customPage .marginBox.origami-layout-mode").length) { setupLayoutMode(); $("#myonoffswitch").prop("checked", true); } - $(".customPage, .above-page-control-container") - .find("*[data-i18n]") - .localize(); + const localizableElements = $( + ".customPage, .above-page-control-container", + ).find("*[data-i18n]"); + // In some dev/runtime paths the jQuery localize plugin is not loaded. + try { + if (typeof localizableElements.localize === "function") { + localizableElements.localize(); + } + } catch (error) { + console.warn( + "Origami localization failed; continuing with default labels.", + error, + ); + } + + ensurePageSettingsButtonHasIcon(); }); }); } +function ensurePageSettingsButtonHasIcon() { + $(".page-settings-button").each((_index, element) => { + const button = $(element); + const labelText = $.trim(button.text()) || "Page Settings"; + button.empty(); + button.append($(getPageSettingsButtonIconHtml())); + button.append( + $("").text( + labelText, + ), + ); + }); +} + +function getPageSettingsButtonIconHtml(): string { + return ``; +} + export function cleanupOrigami() { // Otherwise, we get a new one each time the page is loaded $(".split-pane-resize-shim").remove(); @@ -338,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 @@ -348,11 +390,25 @@ function getAbovePageControlContainer(): JQuery { .getElementsByClassName("bloom-page")[0] ?.getAttribute("data-tool-id") === "game" ) { - return $("
"); + return $( + `
\ +${getPageSettingsButtonHtml()}\ +
`, + ); } + + if (!showOrigamiControls) { + return $( + `
\ +${getPageSettingsButtonHtml()}\ +
`, + ); + } + return $( - "\ + `\
\ +${getPageSettingsButtonHtml()}\
\
Change Layout
\
\ @@ -363,10 +419,19 @@ function getAbovePageControlContainer(): JQuery { \
\
\ -
", +`, ); } +function getPageSettingsButtonHtml(): string { + return ``; +} + +function pageSettingsButtonClickHandler(e: Event) { + e.preventDefault(); + post("editView/showPageSettingsDialog"); +} + function getButtons() { const buttons = $( "
", diff --git a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx index 9a313beca1c8..d44c69b4f4c7 100644 --- a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx +++ b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx @@ -1,6 +1,7 @@ import { css } from "@emotion/react"; import * as React from "react"; import { + ConfigrValues, ConfigrGroup, ConfigrPage, ConfigrPane, @@ -27,7 +28,7 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { propsForBloomDialog, } = useEventLaunchedBloomDialog("CollectionSettingsDialog"); - const [settings, setSettings] = React.useState( + const [settings, setSettings] = React.useState( undefined, ); @@ -41,29 +42,17 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { }, [propsForBloomDialog.open]); const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< - string | object | undefined + ConfigrValues | undefined >(undefined); - - const normalizeConfigrSettings = ( - settingsValue: string | object | undefined, - ): object | undefined => { - if (!settingsValue) { - return undefined; - } - if (typeof settingsValue === "string") { - return JSON.parse(settingsValue) as object; - } - return settingsValue; - }; // Parse the settings JSON for Configr's initial values once it arrives. React.useEffect(() => { if (settingsString === "{}") { return; // leave settings as undefined } if (typeof settingsString === "string") { - setSettings(JSON.parse(settingsString)); + setSettings(JSON.parse(settingsString) as ConfigrValues); } else { - setSettings(settingsString); + setSettings(settingsString as ConfigrValues); } }, [settingsString]); @@ -150,11 +139,11 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { { - const settingsToPost = normalizeConfigrSettings( - settingsToReturnLater, - ); - if (settingsToPost) { - postJson("collection/settings", settingsToPost); + if (settingsToReturnLater) { + postJson( + "collection/settings", + settingsToReturnLater, + ); } closeDialog(); }} diff --git a/src/BloomBrowserUI/collectionsTab/BookButton.tsx b/src/BloomBrowserUI/collectionsTab/BookButton.tsx index 02cca9d15dea..f7c0f621067a 100644 --- a/src/BloomBrowserUI/collectionsTab/BookButton.tsx +++ b/src/BloomBrowserUI/collectionsTab/BookButton.tsx @@ -28,7 +28,7 @@ import { makeMenuItems, MenuItemSpec } from "./menuHelpers"; import DeleteIcon from "@mui/icons-material/Delete"; import { useL10n } from "../react_components/l10nHooks"; import SettingsIcon from "@mui/icons-material/Settings"; -import { showBookSettingsDialog } from "../bookEdit/bookSettings/BookSettingsDialog"; +import { showBookSettingsDialog } from "../bookEdit/bookAndPageSettings/BookAndPageSettingsDialog"; import { BookOnBlorgBadge } from "../react_components/BookOnBlorgBadge"; export const bookButtonHeight = 120; diff --git a/src/BloomBrowserUI/package.json b/src/BloomBrowserUI/package.json index 1f31263288aa..35903dcba019 100644 --- a/src/BloomBrowserUI/package.json +++ b/src/BloomBrowserUI/package.json @@ -128,7 +128,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.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/react_components/BookInfoIndicator.tsx b/src/BloomBrowserUI/react_components/BookInfoIndicator.tsx index 499e85cd6772..2edd31b9dbb1 100644 --- a/src/BloomBrowserUI/react_components/BookInfoIndicator.tsx +++ b/src/BloomBrowserUI/react_components/BookInfoIndicator.tsx @@ -12,7 +12,7 @@ import { MessageUsingMigratedThemeInsteadOfIncompatibleCss, MessageUsingLegacyThemeWithIncompatibleCss, MessageIgnoringIncompatibleCssCanDelete, -} from "../bookEdit/bookSettings/BookSettingsDialog"; +} from "../bookEdit/bookAndPageSettings/BookSettingsConfigrPages"; export const BookInfoIndicator: React.FunctionComponent<{ bookId: string; 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; diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx index f4d3a8f4926d..4a80045da69a 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. @@ -50,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: colorChoice.opacity, // Don't change opacity + colors: [colorOnly], + opacity: newOpacity, }; changeColor(newColor); }; @@ -81,11 +208,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 +282,51 @@ 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 6688187f6c7b..e2612ddb9806 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx @@ -1,7 +1,7 @@ -import { css } from "@emotion/react"; +import { css, Global } from "@emotion/react"; import * as React from "react"; import * as ReactDOM from "react-dom"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getEditTabBundleExports } from "../../bookEdit/js/bloomFrames"; 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,119 +321,42 @@ const ColorPickerDialog: React.FC = (props) => { } }; - // We come to here on opening to add colors already in the book and we come here on closing to see - // if our new current color needs to be added to our array. - // Enhance: What if the number of distinct colors already used in the book that we get back, plus the number - // of other default colors is more than will fit in our array (current 21)? When we get colors from the book, - // we should maybe start with the current page, to give them a better chance of being included in the picker. - const addNewColorsToArrayIfNecessary = (newColors: IColorInfo[]) => { - // Every time we reference the current swatchColorArray inside - // this setter, we must use previousSwatchColorArray. - // Otherwise, we add to a stale array. - setSwatchColorArray((previousSwatchColorArray) => { - const newColorsAdded: IColorInfo[] = []; - const lengthBefore = previousSwatchColorArray.length; - let numberToDelete = 0; - // CustomColorPicker is going to filter these colors out anyway. - let numberToSkip = previousSwatchColorArray.filter((color) => - willSwatchColorBeFilteredOut(color), - ).length; - newColors.forEach((newColor) => { - if (isColorInThisArray(newColor, previousSwatchColorArray)) { - return; // This one is already in our array of swatch colors - } - if (isColorInThisArray(newColor, newColorsAdded)) { - return; // We don't need to add the same color more than once! - } - // At first I wanted to do this filtering outside the loop, but some of them might be pre-filtered - // by the above two conditions. - if (willSwatchColorBeFilteredOut(newColor)) { - numberToSkip++; - } - if ( - lengthBefore + newColorsAdded.length + 1 > - MAX_SWATCHES + numberToSkip - ) { - numberToDelete++; - } - newColorsAdded.unshift(newColor); // add newColor to the beginning of the array. - }); - const newSwatchColorArray = swatchColorArray.slice(); // Get a new array copy of the old (a different reference) - if (numberToDelete > 0) { - // Remove 'numberToDelete' swatches from oldest custom swatches - const defaultNumber = getDefaultColorsFromPalette( - props.palette, - ).length; - const indexToRemove = - swatchColorArray.length - defaultNumber - numberToDelete; - if (indexToRemove >= 0) { - newSwatchColorArray.splice(indexToRemove, numberToDelete); - } else { - const excess = indexToRemove * -1; // index went negative; excess is absolute value - newSwatchColorArray.splice(0, numberToDelete - excess); - newColorsAdded.splice( - newColorsAdded.length - excess, - excess, - ); - } - } - const result = newColorsAdded.concat(previousSwatchColorArray); - //console.log(result); - return result; - }); - }; - const isColorInCurrentSwatchColorArray = (color: IColorInfo): boolean => isColorInThisArray(color, swatchColorArray); - const willSwatchColorBeFilteredOut = (color: IColorInfo): boolean => { - if (!props.transparency && color.opacity !== 1) { - return true; - } - if (props.noGradientSwatches && color.colors.length > 1) { - return true; - } - return false; + const handleOnChange = (color: IColorInfo) => { + const clonedColor: IColorInfo = { + ...color, + colors: [...color.colors], + }; + setCurrentColor(clonedColor); + props.onChange(clonedColor); }; - // Use a compare function to see if the color in question matches on already in this list or not. - const isColorInThisArray = ( - color: IColorInfo, - arrayOfColors: IColorInfo[], - ): boolean => !!arrayOfColors.find(colorCompareFunc(color)); - - // Function for comparing a color with an array of colors to see if the color is already - // in the array. We pass this function to .find(). - const colorCompareFunc = - (colorA: IColorInfo) => - (colorB: IColorInfo): boolean => { - if (colorB.colors.length !== colorA.colors.length) { - return false; // One is a gradient and the other is not. - } - if (colorA.colors.length > 1) { - // In the case of both being gradients, check the second color first. - const gradientAColor2 = tinycolor(colorA.colors[1]); - const gradientBColor2 = tinycolor(colorB.colors[1]); - if (gradientAColor2.toHex() !== gradientBColor2.toHex()) { - return false; - } - } - const gradientAColor1 = tinycolor(colorA.colors[0]); - const gradientBColor1 = tinycolor(colorB.colors[0]); - return ( - gradientAColor1.toHex() === gradientBColor1.toHex() && - colorA.opacity === colorB.opacity - ); - }; + const dialogOpen = props.open === undefined ? open : props.open; - const handleOnChange = (color: IColorInfo) => { - setCurrentColor(color); - props.onChange(color); - }; + // 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, reason: "backdropClick" | "escapeKeyDown", ) => { - if (reason === "backdropClick") - onClose(DialogResult.OK); + if (eyedropperActive) { + return; + } if (reason === "escapeKeyDown") onClose(DialogResult.Cancel); }} @@ -330,6 +411,7 @@ const ColorPickerDialog: React.FC = (props) => { noGradientSwatches={props.noGradientSwatches} includeDefault={props.includeDefault} onDefaultClick={props.onDefaultClick} + onEyedropperActiveChange={setEyedropperActive} //defaultColor={props.defaultColor} /> @@ -366,13 +448,7 @@ export const showColorPickerDialog = ( }; export const hideColorPickerDialog = () => { - // I'm not sure if this can be falsy, but whereas in the method above we're calling it - // immediately after we render the dialog, which sets it, this gets called long after - // when the tool is closed. Just in case it somehow gets cleared, now or in some future - // version of the code, I decided to leave in the check that CoPilot proposed. - if (externalSetOpen) { - externalSetOpen(false); - } + externalSetOpen(false); }; const doRender = ( @@ -412,12 +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; @@ -429,18 +514,32 @@ export interface IColorDisplayButtonProps { width?: number; 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( + dialogOpen ? colorAtDialogOpen : props.initialColor, + ), + [props.initialColor, dialogOpen, colorAtDialogOpen], + ); + useEffect(() => { if (currentButtonColor !== props.initialColor) { setCurrentButtonColor(props.initialColor); @@ -452,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); }} /> @@ -480,23 +593,31 @@ 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} transparency={props.transparency} palette={props.palette} - initialColor={getColorInfoFromSpecialNameOrColorString( - props.initialColor, - )} + initialColor={initialColorInfo} onInputFocus={() => {}} - onChange={(color: IColorInfo) => - setCurrentButtonColor(color.colors[0]) - } + onChange={(color: IColorInfo) => { + const newColor = getColorStringFromColorInfo(color); + setCurrentButtonColor(newColor); + if (props.onChange) { + props.onChange(newColor); + } + }} />
); 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..3e3a03422b9b 100644 --- a/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx @@ -7,54 +7,92 @@ import { IColorInfo } from "./colorSwatch"; interface IHexColorInputProps { initial: IColorInfo; onChangeComplete: (newValue: string) => void; + includeOpacityChannel?: boolean; } const hashChar = "#"; +const massageColorInput = ( + color: string, + includeOpacityChannel?: boolean, +): string => { + let result = color.toUpperCase(); + result = result.replace(/[^0-9A-F]/g, ""); // eliminate any non-hex characters + result = hashChar + result; // insert hash as the first character + const maxLength = includeOpacityChannel ? 9 : 7; + if (result.length > maxLength) { + result = result.slice(0, maxLength); + } + return result; +}; + +// In general, we want our Hex Color input to reflect the first value in the 'colors' array. +// For our predefined gradients, however, we want the hex input to be empty. +// And for named colors, we need to show the hex equivalent. +const getHexColorValueFromColorInfo = ( + colorInfo: IColorInfo, + includeOpacityChannel?: boolean, +): string => { + // First, our hex value will be empty, if we're dealing with a gradient. + // The massage method below will add a hash character... + if (colorInfo.colors.length > 1) return ""; + const firstColor = colorInfo.colors[0]; + const hexColor = tinycolor(firstColor).toHexString(); + + if (!includeOpacityChannel) { + return hexColor; + } + + const alphaHex = Math.round(colorInfo.opacity * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `${hexColor}${alphaHex}`; +}; + export const HexColorInput: React.FunctionComponent = ( props, ) => { - const [currentColor, setCurrentColor] = useState(""); + const getHexValue = React.useCallback( + (colorInfo: IColorInfo): string => + massageColorInput( + getHexColorValueFromColorInfo( + colorInfo, + props.includeOpacityChannel, + ), + props.includeOpacityChannel, + ), + [props.includeOpacityChannel], + ); - // In general, we want our Hex Color input to reflect the first value in the 'colors' array. - // For our predefined gradients, however, we want the hex input to be empty. - // And for named colors, we need to show the hex equivalent. - const getHexColorValueFromColorInfo = (): string => { - // First, our hex value will be empty, if we're dealing with a gradient. - // The massage method below will add a hash character... - if (props.initial.colors.length > 1) return ""; - const firstColor = props.initial.colors[0]; - if (firstColor[0] === hashChar) return firstColor; - // In some cases we might be dealing with a color word like "black" or "white" or "transparent". - return tinycolor(firstColor).toHexString(); - }; + const [currentColor, setCurrentColor] = useState(() => + getHexValue(props.initial), + ); - const massageColorInput = (color: string): string => { - let result = color.toUpperCase(); - result = result.replace(/[^0-9A-F]/g, ""); // eliminate any non-hex characters - result = hashChar + result; // insert hash as the first character - if (result.length > 7) { - result = result.slice(0, 7); - } - return result; - }; + const initialHexValue = getHexValue(props.initial); + // Keep the displayed hex string in sync when the parent changes the color programmatically + // (e.g. swatch click, eyedropper, or external currentColor updates). useEffect(() => { - setCurrentColor(massageColorInput(getHexColorValueFromColorInfo())); - }, [props.initial.colors]); + setCurrentColor(initialHexValue); + }, [initialHexValue]); const handleInputChange: React.ChangeEventHandler = ( e, ) => { - const result = massageColorInput(e.target.value); + const result = massageColorInput( + e.target.value, + props.includeOpacityChannel, + ); setCurrentColor(result); - if (result.length === 7) { + const completeLength = props.includeOpacityChannel ? 9 : 7; + if (result.length === completeLength) { props.onChangeComplete(result); } }; const borderThickness = 2; - const controlWidth = 60; // This width handles "#DDDDDD" as the maximum width input. + const controlWidth = props.includeOpacityChannel ? 80 : 60; const inputWidth = controlWidth - 2 * borderThickness; return ( diff --git a/src/BloomBrowserUI/react_components/color-picking/show.sh b/src/BloomBrowserUI/react_components/color-picking/show.sh new file mode 100644 index 000000000000..8ec1f479cb96 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/show.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Manual testing for color-picking +# Uses Playwright with full mock support from test-helpers.ts +# Usage: ./show.sh [test-name] + +set -euo pipefail + +COMPONENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPONENT_NAME="$(basename "$COMPONENT_DIR")" + +cd "$COMPONENT_DIR/../component-tester" + +./show-component.sh "$COMPONENT_NAME" "$@" diff --git a/src/BloomBrowserUI/react_components/color-picking/test.sh b/src/BloomBrowserUI/react_components/color-picking/test.sh new file mode 100644 index 000000000000..fddac4e6631c --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/test.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Run automated UI tests for this component +set -e + +script_dir="$(cd "$(dirname "$0")" && pwd)" +cd "$script_dir/../component-tester" + +component_path="../color-picking/component-tests" + +if [ "${1:-}" = "--ui" ]; then + shift + yarn test:ui "$component_path" "$@" +else + yarn test "$component_path" "$@" +fi diff --git a/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts b/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts new file mode 100644 index 000000000000..50c427ffd3eb --- /dev/null +++ b/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts @@ -0,0 +1,50 @@ +export type ElementAttributeMap = { + [attributeName: string]: string; +}; + +export class ElementAttributeSnapshot { + private readonly attributes: ElementAttributeMap; + + private constructor(attributes: ElementAttributeMap) { + this.attributes = attributes; + } + + public static fromElement = ( + element: Element, + ): ElementAttributeSnapshot => { + const snapshot: ElementAttributeMap = {}; + for (let index = 0; index < element.attributes.length; index++) { + const attribute = element.attributes.item(index); + if (attribute) { + snapshot[attribute.name] = attribute.value; + } + } + + return new ElementAttributeSnapshot(snapshot); + }; + + public restoreToElement = (element: Element): void => { + const currentAttributeNames: string[] = []; + for (let index = 0; index < element.attributes.length; index++) { + const attribute = element.attributes.item(index); + if (attribute) { + currentAttributeNames.push(attribute.name); + } + } + + currentAttributeNames.forEach((attributeName) => { + if ( + !Object.prototype.hasOwnProperty.call( + this.attributes, + attributeName, + ) + ) { + element.removeAttribute(attributeName); + } + }); + + Object.keys(this.attributes).forEach((attributeName) => { + element.setAttribute(attributeName, this.attributes[attributeName]); + }); + }; +} diff --git a/src/BloomBrowserUI/yarn.lock b/src/BloomBrowserUI/yarn.lock index 1baf3249dec2..12759150a0f9 100644 --- a/src/BloomBrowserUI/yarn.lock +++ b/src/BloomBrowserUI/yarn.lock @@ -2930,10 +2930,10 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz#1657f56326bbe0ac80eedc9f9c18fc1ddd24e107" integrity sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg== -"@sillsdev/config-r@1.0.0-alpha.18": - version "1.0.0-alpha.18" - resolved "https://registry.npmjs.org/@sillsdev/config-r/-/config-r-1.0.0-alpha.18.tgz#177178ec2bba9e2843a3edab949c6b6489f0286d" - integrity sha512-EFiyAwUTMJ4jlvXRMBsO4+Zm8Gkaur+idUB3czXADqE0zG8ZnrMug951dWv67uFLH6hZT9jhGasEsHU1G/2/qA== +"@sillsdev/config-r@1.0.0-alpha.22": + version "1.0.0-alpha.22" + resolved "https://registry.npmjs.org/@sillsdev/config-r/-/config-r-1.0.0-alpha.22.tgz#2a8bbbf2c73008a342cf1a8d0304bf0076ec1586" + integrity sha512-6tH8KuPSGKPYSb8n2Prl8VHC45ggW5Uq+GbGEtrDY4dONLcN7GHANiZrZuJBXGNzK2yScuYjby2NWmoelvbAgg== dependencies: "@textea/json-viewer" "^2.13.1" formik "^2.2.9" diff --git a/src/BloomExe/Book/HtmlDom.cs b/src/BloomExe/Book/HtmlDom.cs index 819c55b71910..6d4a5ad29220 100644 --- a/src/BloomExe/Book/HtmlDom.cs +++ b/src/BloomExe/Book/HtmlDom.cs @@ -1880,6 +1880,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 @@ -1915,6 +1952,14 @@ SafeXmlElement edittedPageDiv //html file in a browser. destinationPageDiv.SetAttribute("lang", edittedPageDiv.GetAttribute("lang")); + // Save only the page color custom properties we manage in Page Settings. + // If all are missing, remove any previously-saved page-level custom properties. + var style = GetPersistedPageStyleValue(edittedPageDiv); + if (string.IsNullOrEmpty(style)) + destinationPageDiv.RemoveAttribute("style"); + else + destinationPageDiv.SetAttribute("style", style); + // Copy the two background audio attributes which can be set using the music toolbox. // Ensuring that volume is missing unless the main attribute is non-empty is // currently redundant, everything should work if we just copied all attributes. diff --git a/src/BloomExe/Edit/EditingModel.cs b/src/BloomExe/Edit/EditingModel.cs index ca4c63e4b4de..096c1c2999cb 100644 --- a/src/BloomExe/Edit/EditingModel.cs +++ b/src/BloomExe/Edit/EditingModel.cs @@ -1323,7 +1323,7 @@ internal string GetUrlForPageListFile() OptimizeForLinux(pageListDom); pageListDom = CurrentBook.GetHtmlDomForPageList(pageListDom); - var url =_view.Browser.CreateSimulatedFile( + var url = _view.Browser.CreateSimulatedFile( pageListDom, false, InMemoryHtmlFileSource.Pagelist diff --git a/src/BloomExe/Edit/EditingView.cs b/src/BloomExe/Edit/EditingView.cs index 2dd5ae99a9a6..ca8a40bfb7c6 100644 --- a/src/BloomExe/Edit/EditingView.cs +++ b/src/BloomExe/Edit/EditingView.cs @@ -1891,10 +1891,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( - $"editTabBundle.showEditViewBookSettingsDialog({groupIndex});" + $"editTabBundle.showEditViewBookSettingsDialog('{pageKey}');" ); return _model.CurrentPage.Id; }, @@ -1902,6 +1902,26 @@ public void SaveAndOpenBookSettingsDialog() ); } + public void SaveAndOpenPageSettingsDialog() + { + _model.SaveThen( + () => + { + RunJavascriptAsync("editTabBundle.showEditViewPageSettingsDialog();"); + return _model.CurrentPage.Id; + }, + () => { } // wrong state, do nothing + ); + } + + // 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 3cd2939d3769..e339f9abd8f4 100644 --- a/src/BloomExe/web/controllers/EditingViewApi.cs +++ b/src/BloomExe/web/controllers/EditingViewApi.cs @@ -115,6 +115,11 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) HandleShowBookSettingsDialog, true ); + apiHandler.RegisterEndpointHandler( + "editView/showPageSettingsDialog", + HandleShowPageSettingsDialog, + true + ); } private void HandleJumpToPage(ApiRequest request) @@ -130,6 +135,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/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..aa769b7c0a0d 100644 --- a/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css +++ b/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css @@ -26,16 +26,16 @@ .numberedPage:where([class*="Device"]:not(.bloom-interactive-page)) { --topLevel-text-padding: 0.5em; } - [class*="Device"].numberedPage:not(.bloom-interactive-page) { --pageNumber-extra-height: 0mm !important; /* we put the page number on top of the image so we don't need a margin boost */ + --pageNumber-background-color: #ffffff; /* I'm not clear why this is white, but all I did in this change is to move it so that it can be overridden by page settings */ } [class*="Device"].numberedPage:not(.bloom-interactive-page)::after { --pageNumber-bottom: var(--page-margin-bottom); --pageNumber-top: unset; --pageNumber-font-size: 11pt; --pageNumber-border-radius: 50%; - --pageNumber-background-color: #ffffff; + --pageNumber-background-width: 33px; --pageNumber-always-left-margin: var(--page-margin-left); --pageNumber-right-margin: deliberately-invalid; /* prevents right being set at all. unset does not work. Prevent centering for this layout */ diff --git a/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css b/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css index 2d96796458b6..8828c2daceca 100644 --- a/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css +++ b/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css @@ -17,8 +17,8 @@ Note that hiding the page numbers is done by a setting in appearance.json, not h --page-horizontalSplit-height: 0mm; } -/* The section below controls the page number and the white circle around it. */ -.Device16x9Landscape.numberedPage { +.numberedPage { + --pageNumber-background-color: #ffffff; /* I'm not clear why this is white, but all I did in this change is to move it so that it can be overridden by page settings */ --pageNumber-extra-height: 0mm !important; /* we put the page number on top of the image so we don't need a margin boost */ } .Device16x9Portrait.numberedPage { @@ -41,7 +41,7 @@ Note that hiding the page numbers is done by a setting in appearance.json, not h --pageNumber-font-size: 11pt; border-radius: 50%; - --pageNumber-background-color: #ffffff; + --pageNumber-background-width: 33px; --pageNumber-always-left-margin: var(--page-margin-left); --pageNumber-right-margin: deliberately-invalid; /* prevents right being set at all. unset does not work. Prevent centering for this layout */ diff --git a/src/content/bookLayout/pageNumbers.less b/src/content/bookLayout/pageNumbers.less index 254a9ca3a3e6..13b31b1e0b54 100644 --- a/src/content/bookLayout/pageNumbers.less +++ b/src/content/bookLayout/pageNumbers.less @@ -7,6 +7,7 @@ // themes can override this as needed. If you have reasonable margins, you don't need to add anything to fit in a pageNumber --pageNumber-extra-height: 0mm; // must have units } + .numberedPage { &:after { content: attr(data-page-number); @@ -22,6 +23,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 + +