diff --git a/.github/prompts/bloom-l10.prompt.md b/.github/prompts/bloom-l10.prompt.md index eba2cf0c3f05..117895643979 100644 --- a/.github/prompts/bloom-l10.prompt.md +++ b/.github/prompts/bloom-l10.prompt.md @@ -36,9 +36,13 @@ the string id may be used by translators as they try to understand context or tr ## Expose ID to translators Add a note like this: `ID: LinkTargetChooser.URL.Paste.Tooltip` +## Legacy strings +Our localization build system is such that if we are no longer using a string ID in the next version, we cannot remove the ID from the XLF file immediately. This is because if we release a new version of the previous release, we will still need that old localization ID. The fact that its code base still has it will not be sufficient. Somehow the actual Crowdin database will lose the translations when it sees the string ID removed from a newer version. Therefore when we stop using a string ID we just add a note like this: "Obsolete as of 6.2". You can figure out the current version from the `Version` property on build/Bloom.proj. + ## Add comments for translators Although we don't want to fill in l10nComment in useL10n, we do want to fill in the note field to give context to translators. They don't know where the string appears in the UI, they also might need some explanation of what it means. For example, for the above string, we might add a note like `This is the text on a button in the Foobar dialog that brightens all images in the current book.` # Tips * Never use the word "Aria" in ids or comments. Translators don't know what that means. * Stop processing immediately if I haven't told you what priority we want. After you have the priority, then you can continue. + diff --git a/.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/bloom-automation/SKILL.md b/.github/skills/bloom-automation/SKILL.md index 16919020fbf8..2d4283b41b13 100644 --- a/.github/skills/bloom-automation/SKILL.md +++ b/.github/skills/bloom-automation/SKILL.md @@ -1,6 +1,6 @@ --- name: bloom-automation -description: Use when an agent needs to determine if Bloom is already running, detect whether the running Bloom came from a different worktree, kill Bloom or dotnet-watch parents, start Bloom from the current worktree, attach to the embedded WebView2 over CDP, inspect DOM/console/network, or run Playwright tests against the actual exe instead of CURRENTPAGE. +description: Use when an agent needs to determine if Bloom is already running, detect whether the running Bloom came from a different worktree, kill Bloom or dotnet-watch parents, start Bloom from the current worktree, attach to the embedded WebView2 over CDP, inspect DOM/console/network, use dev-browser to inspect or run e2e tests against the actual exe instead of CURRENTPAGE. argument-hint: "repo root or worktree, task such as status, restart, attach, run exe-backed tests" user-invocable: true --- @@ -219,8 +219,9 @@ These tests attach to the real Bloom.exe target over CDP and verify tab switchin - Exact-target cleanup is intentionally strict: `killBloomProcess.mjs --http-port ` should only kill the instance that actually reports that HTTP port, and should fail without killing anything if that target cannot be resolved. - When reporting work, include the helper commands you used so reviewers can confirm the workflow stayed on the supported path. - Wrong-worktree detection is authoritative when a real `Bloom.exe` child exists or when `dotnet watch` was started with an absolute `--project` path. -- A standalone `dotnet watch` started with a relative project path may not expose enough information to attribute it to a worktree. For current-worktree automation, start Bloom through `node scripts/watchBloomExe.mjs`, which always uses an absolute path. For the already-running Bloom workflow, use `--running-bloom` instead of trying to infer a worktree. - When more than one Bloom is running from the same worktree, repo-root matching is not enough. Use the explicit HTTP port workflow. +- For ad hoc local debugging in this workspace, `dev-browser --connect http://localhost:` can attach directly to the existing Bloom WebView2 target. Use it as a low-friction inspection client. +- After attaching to Bloom's WebView2 target, if Bloom is on the Edit tab, the editable page content lives inside the iframe named `page`; the top-level document mostly hosts shell UI plus the root dialog container. ## Completion Checks - Bloom's status is known: not running, running from current worktree, or running from different worktree. @@ -243,7 +244,7 @@ Report: - what browser-native evidence you collected: DOM state, console output, network request, tab state, or test results ## Example Prompts -- `Use bloom-automation to determine whether Bloom is already running from this worktree and attach Playwright to the embedded browser.` -- `Use bloom-automation to switch the already-running Bloom to the Edit tab.` -- `Use bloom-automation to kill the wrong-worktree Bloom and start the current checkout with dotnet watch.` -- `Use bloom-automation to run the exe-backed Playwright top bar smoke tests against the actual Bloom.exe window.` +- `troubleshoot why the page is refreshing when we open page settings` + +## Debugging tips +Use node or bash scripts. Avoid powershell. Use the "dev-browser" cli instead of playwright for interactive debugging/driving Bloom. Use "dev-browser --help" to see the available commands and options. If the user hasn't installed dev-browser, ask them for permission to install it (https://github.com/SawyerHood/dev-browser). diff --git a/.github/skills/bloom-automation/bloomProcessCommon.mjs b/.github/skills/bloom-automation/bloomProcessCommon.mjs index c2b4197976e1..9da076110fe5 100644 --- a/.github/skills/bloom-automation/bloomProcessCommon.mjs +++ b/.github/skills/bloom-automation/bloomProcessCommon.mjs @@ -107,6 +107,9 @@ export const extractRepoRoot = (text) => { export const normalizeBloomInstanceInfo = (info, discoveredViaPort) => { const httpPort = toTcpPort(info?.httpPort) ?? discoveredViaPort; const cdpPort = toTcpPort(info?.cdpPort); + const executablePath = normalizePath(info?.executablePath); + const detectedRepoRoot = extractRepoRoot(executablePath); + const vitePort = toTcpPort(info?.vitePort); return { processId: toPositiveInteger(info?.processId), @@ -114,6 +117,9 @@ export const normalizeBloomInstanceInfo = (info, discoveredViaPort) => { httpPort, origin: toLocalOrigin(httpPort), cdpPort, + executablePath, + detectedRepoRoot, + vitePort, }; }; diff --git a/.github/skills/react-useeffect/README.md b/.github/skills/react-useeffect/README.md new file mode 100644 index 000000000000..6591ab8cab3d --- /dev/null +++ b/.github/skills/react-useeffect/README.md @@ -0,0 +1,320 @@ +# React useEffect Best Practices + +A comprehensive guide teaching when to use `useEffect` in React, and more importantly, when NOT to use it. This skill is based on official React documentation and provides practical alternatives to common useEffect anti-patterns. + +## Purpose + +Effects are an **escape hatch** from React's reactive paradigm. They let you synchronize with external systems like browser APIs, third-party widgets, or network requests. However, many developers overuse Effects for tasks that React handles better through other means. + +This skill helps you: +- Identify when you truly need an Effect vs. when you don't +- Recognize common anti-patterns and their fixes +- Apply better alternatives like `useMemo`, `key` prop, and event handlers +- Write Effects that are clean, maintainable, and free from race conditions + +## When to Use This Skill + +Use this skill when you're: +- Writing or reviewing `useEffect` code +- Using `useState` to store derived values +- Implementing data fetching or subscriptions +- Synchronizing state between components +- Facing bugs with stale data or race conditions +- Wondering if your Effect is necessary + +**Trigger phrases:** +- "Should I use useEffect for this?" +- "How do I fix this useEffect?" +- "My Effect is causing too many re-renders" +- "Data fetching with useEffect" +- "Reset state when props change" +- "Derived state from props" + +## How It Works + +This skill provides guidance through four key resources: + +1. **Quick Reference Table** - Fast lookup for common scenarios with DO/DON'T patterns +2. **Decision Tree** - Visual flowchart to determine the right approach +3. **Detailed Anti-Patterns** - 9 common mistakes with explanations and fixes +4. **Better Alternatives** - 8 proven patterns to replace unnecessary Effects + +The skill teaches you to ask the right questions: +- Is there an external system involved? +- Am I responding to a user event or component appearance? +- Can this value be calculated during render? +- Do I need to reset state when a prop changes? + +## Key Features + +### 1. Quick Reference Guide + +Visual table showing the DO/DON'T for common scenarios: +- Derived state from props/state +- Expensive calculations +- Resetting state on prop change +- User event responses +- Notifying parent components +- Data fetching + +### 2. Decision Tree + +Clear flowchart that guides you from "Need to respond to something?" to the correct solution: +- User interaction → Event handler +- Component appeared → Effect (for external sync/analytics) +- Derived value needed → Calculate during render (+ useMemo if expensive) +- Reset state on prop change → Key prop + +### 3. Anti-Pattern Recognition + +Detailed examples of 9 common mistakes: +1. Redundant state for derived values +2. Filtering/transforming data in Effect +3. Resetting state on prop change +4. Event-specific logic in Effect +5. Chains of Effects +6. Notifying parent via Effect +7. Passing data up to parent +8. Fetching without cleanup (race conditions) +9. App initialization in Effect + +Each anti-pattern includes: +- Bad example with explanation +- Good example with fix +- Why the anti-pattern is problematic + +### 4. Better Alternatives + +8 proven patterns to replace unnecessary Effects: +1. Calculate during render for derived state +2. `useMemo` for expensive calculations +3. `key` prop to reset state +4. Store ID instead of object for stable references +5. Event handlers for user actions +6. `useSyncExternalStore` for external stores +7. Lifting state up for shared state +8. Custom hooks for data fetching with cleanup + +## Usage Examples + +### Example 1: Derived State + +**Bad - Unnecessary Effect:** +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); +} +``` + +**Good - Calculate during render:** +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const fullName = firstName + ' ' + lastName; // Just compute it +} +``` + +### Example 2: Resetting State + +**Bad - Effect to reset:** +```tsx +function ProfilePage({ userId }) { + const [comment, setComment] = useState(''); + + useEffect(() => { + setComment(''); + }, [userId]); +} +``` + +**Good - Key prop:** +```tsx +function ProfilePage({ userId }) { + return ; +} + +function Profile({ userId }) { + const [comment, setComment] = useState(''); // Resets automatically +} +``` + +### Example 3: Data Fetching with Cleanup + +**Bad - Race condition:** +```tsx +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + fetchResults(query).then(json => { + setResults(json); // "hello" response may arrive after "hell" + }); + }, [query]); +} +``` + +**Good - Cleanup flag:** +```tsx +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + let ignore = false; + + fetchResults(query).then(json => { + if (!ignore) setResults(json); + }); + + return () => { ignore = true; }; + }, [query]); +} +``` + +### Example 4: Event Handler Instead of Effect + +**Bad - Effect watching state:** +```tsx +function ProductPage({ product, addToCart }) { + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name}!`); + } + }, [product]); + + function handleBuyClick() { + addToCart(product); + } +} +``` + +**Good - Handle in event:** +```tsx +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + } +} +``` + +## When You DO Need Effects + +Effects are appropriate for: + +- **Synchronizing with external systems** - Browser APIs, third-party widgets, non-React code +- **Subscriptions** - WebSocket connections, global event listeners (prefer `useSyncExternalStore`) +- **Analytics/logging** - Code that needs to run because the component displayed +- **Data fetching** - With proper cleanup (or use your framework's built-in mechanism) + +## When You DON'T Need Effects + +Avoid Effects for: + +1. **Transforming data for rendering** - Calculate at the top level instead +2. **Handling user events** - Use event handlers where you know exactly what happened +3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` +4. **Chaining state updates** - Calculate all next state in the event handler +5. **Notifying parent components** - Call the callback in the same event handler +6. **Resetting state** - Use the `key` prop to create a fresh component instance + +## Best Practices + +### 1. Start Without an Effect + +Before adding an Effect, ask: "Is there an external system involved?" If no, you probably don't need an Effect. + +### 2. Prefer Derived State + +If you can calculate a value from props or state, don't store it in state with an Effect updating it. + +### 3. Use the Right Tool + +- Expensive calculation → `useMemo` +- User interaction → Event handler +- Reset on prop change → `key` prop +- External subscription → `useSyncExternalStore` +- Shared state → Lift state up + +### 4. Always Clean Up + +If your Effect subscribes, fetches, or sets timers, return a cleanup function to prevent memory leaks and race conditions. + +### 5. Avoid Effect Chains + +Multiple Effects triggering each other causes unnecessary re-renders and makes code hard to follow. Calculate everything in one place (usually an event handler). + +### 6. Test in Strict Mode + +React 18+ Strict Mode mounts components twice in development to expose missing cleanup. If your Effect breaks, you need cleanup. + +### 7. Consider Framework Solutions + +For data fetching, prefer your framework's built-in solution (Next.js, Remix) or libraries (React Query, SWR) over manual Effects. + +## Reference Files + +This skill includes three detailed reference documents: + +1. **SKILL.md** - Quick reference table and decision tree +2. **anti-patterns.md** - 9 common mistakes with detailed explanations +3. **alternatives.md** - 8 better alternatives with code examples + +## Common Pitfalls + +### Multiple Re-renders + +**Symptom:** Component re-renders many times in quick succession. + +**Cause:** Effect that sets state based on state it depends on, creating a loop. + +**Fix:** Calculate the final value in an event handler or during render. + +### Stale Data + +**Symptom:** UI shows outdated values briefly before updating. + +**Cause:** Using Effect to update derived state causes an extra render pass. + +**Fix:** Calculate derived values during render instead of in state. + +### Race Conditions + +**Symptom:** Fast typing shows results for old queries after new ones. + +**Cause:** Missing cleanup in data fetching Effect. + +**Fix:** Use cleanup flag (`ignore` variable) or AbortController. + +### Runs Twice in Development + +**Symptom:** Effect runs twice on component mount in development. + +**Cause:** React 18 Strict Mode intentionally mounts components twice to expose bugs. + +**Fix:** Add proper cleanup. If it's app initialization that shouldn't run twice, use a module-level guard. + +## Resources + +This skill is based on: +- [React Official Docs: You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) +- [React Official Docs: Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects) +- [React Official Docs: Lifecycle of Reactive Effects](https://react.dev/learn/lifecycle-of-reactive-effects) + +## Summary + +The golden rule: **Effects are an escape hatch from React.** If you're not synchronizing with an external system, you probably don't need an Effect. + +Before writing `useEffect`, ask yourself: +1. Is this responding to a user interaction? → Use event handler +2. Is this a value I can calculate from props/state? → Calculate during render +3. Is this resetting state when a prop changes? → Use key prop +4. Is this synchronizing with an external system? → Use Effect with cleanup + +Follow these patterns, and your React code will be more maintainable, performant, and bug-free. diff --git a/.github/skills/react-useeffect/SKILL.md b/.github/skills/react-useeffect/SKILL.md new file mode 100644 index 000000000000..d7c6ffb23fe4 --- /dev/null +++ b/.github/skills/react-useeffect/SKILL.md @@ -0,0 +1,53 @@ +--- +name: react-useeffect +description: React useEffect best practices from official docs. Use when writing/reviewing useEffect, useState for derived values, data fetching, or state synchronization. Teaches when NOT to use Effect and better alternatives. +--- + +# You Might Not Need an Effect + +Effects are an **escape hatch** from React. They let you synchronize with external systems. If there is no external system involved, you shouldn't need an Effect. + +## Quick Reference + +| Situation | DON'T | DO | +|-----------|-------|-----| +| Derived state from props/state | `useState` + `useEffect` | Calculate during render | +| Expensive calculations | `useEffect` to cache | `useMemo` | +| Reset state on prop change | `useEffect` with `setState` | `key` prop | +| User event responses | `useEffect` watching state | Event handler directly | +| Notify parent of changes | `useEffect` calling `onChange` | Call in event handler | +| Fetch data | `useEffect` without cleanup | `useEffect` with cleanup OR framework | + +## When You DO Need Effects + +- Synchronizing with **external systems** (non-React widgets, browser APIs) +- **Subscriptions** to external stores (use `useSyncExternalStore` when possible) +- **Analytics/logging** that runs because component displayed +- **Data fetching** with proper cleanup (or use framework's built-in mechanism) + +## When You DON'T Need Effects + +1. **Transforming data for rendering** - Calculate at top level, re-runs automatically +2. **Handling user events** - Use event handlers, you know exactly what happened +3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` +4. **Chaining state updates** - Calculate all next state in the event handler + +## Decision Tree + +``` +Need to respond to something? +├── User interaction (click, submit, drag)? +│ └── Use EVENT HANDLER +├── Component appeared on screen? +│ └── Use EFFECT (external sync, analytics) +├── Props/state changed and need derived value? +│ └── CALCULATE DURING RENDER +│ └── Expensive? Use useMemo +└── Need to reset state when prop changes? + └── Use KEY PROP on component +``` + +## Detailed Guidance + +- [Anti-Patterns](./anti-patterns.md) - Common mistakes with fixes +- [Better Alternatives](./alternatives.md) - useMemo, key prop, lifting state, useSyncExternalStore diff --git a/.github/skills/react-useeffect/alternatives.md b/.github/skills/react-useeffect/alternatives.md new file mode 100644 index 000000000000..791744ab7049 --- /dev/null +++ b/.github/skills/react-useeffect/alternatives.md @@ -0,0 +1,258 @@ +# Better Alternatives to useEffect + +## 1. Calculate During Render (Derived State) + +For values derived from props or state, just compute them: + +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + + // Runs every render - that's fine and intentional + const fullName = firstName + ' ' + lastName; + const isValid = firstName.length > 0 && lastName.length > 0; +} +``` + +**When to use**: The value can be computed from existing props/state. + +--- + +## 2. useMemo for Expensive Calculations + +When computation is expensive, memoize it: + +```tsx +import { useMemo } from 'react'; + +function TodoList({ todos, filter }) { + const visibleTodos = useMemo( + () => getFilteredTodos(todos, filter), + [todos, filter] + ); +} +``` + +**How to know if it's expensive**: +```tsx +console.time('filter'); +const visibleTodos = getFilteredTodos(todos, filter); +console.timeEnd('filter'); +// If > 1ms, consider memoizing +``` + +**Note**: React Compiler can auto-memoize, reducing manual useMemo needs. + +--- + +## 3. Key Prop to Reset State + +To reset ALL state when a prop changes, use key: + +```tsx +// Parent passes userId as key +function ProfilePage({ userId }) { + return ( + + ); +} + +function Profile({ userId }) { + // All state here resets when userId changes + const [comment, setComment] = useState(''); + const [likes, setLikes] = useState([]); +} +``` + +**When to use**: You want a "fresh start" when an identity prop changes. + +--- + +## 4. Store ID Instead of Object + +To preserve selection when list changes: + +```tsx +// BAD: Storing object that needs Effect to "adjust" +function List({ items }) { + const [selection, setSelection] = useState(null); + + useEffect(() => { + setSelection(null); // Reset when items change + }, [items]); +} + +// GOOD: Store ID, derive object +function List({ items }) { + const [selectedId, setSelectedId] = useState(null); + + // Derived - no Effect needed + const selection = items.find(item => item.id === selectedId) ?? null; +} +``` + +**Benefit**: If item with selectedId exists in new list, selection preserved. + +--- + +## 5. Event Handlers for User Actions + +User clicks/submits/drags should be handled in event handlers, not Effects: + +```tsx +// Event handler knows exactly what happened +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + analytics.track('product_added', { id: product.id }); + } + + function handleCheckoutClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + navigateTo('/checkout'); + } +} +``` + +**Shared logic**: Extract a function, call from both handlers: + +```tsx +function buyProduct() { + addToCart(product); + showNotification(`Added ${product.name}!`); +} + +function handleBuyClick() { buyProduct(); } +function handleCheckoutClick() { buyProduct(); navigateTo('/checkout'); } +``` + +--- + +## 6. useSyncExternalStore for External Stores + +For subscribing to external data (browser APIs, third-party stores): + +```tsx +// Instead of manual Effect subscription +function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(true); + + useEffect(() => { + function update() { setIsOnline(navigator.onLine); } + window.addEventListener('online', update); + window.addEventListener('offline', update); + return () => { + window.removeEventListener('online', update); + window.removeEventListener('offline', update); + }; + }, []); + + return isOnline; +} + +// Use purpose-built hook +import { useSyncExternalStore } from 'react'; + +function subscribe(callback) { + window.addEventListener('online', callback); + window.addEventListener('offline', callback); + return () => { + window.removeEventListener('online', callback); + window.removeEventListener('offline', callback); + }; +} + +function useOnlineStatus() { + return useSyncExternalStore( + subscribe, + () => navigator.onLine, // Client value + () => true // Server value (SSR) + ); +} +``` + +--- + +## 7. Lifting State Up + +When two components need synchronized state, lift it to common ancestor: + +```tsx +// Instead of syncing via Effects between siblings +function Parent() { + const [value, setValue] = useState(''); + + return ( + <> + + + + ); +} +``` + +--- + +## 8. Custom Hooks for Data Fetching + +Extract fetch logic with proper cleanup: + +```tsx +function useData(url) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let ignore = false; + setLoading(true); + + fetch(url) + .then(res => res.json()) + .then(json => { + if (!ignore) { + setData(json); + setError(null); + } + }) + .catch(err => { + if (!ignore) setError(err); + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + + return () => { ignore = true; }; + }, [url]); + + return { data, error, loading }; +} + +// Usage +function SearchResults({ query }) { + const { data, error, loading } = useData(`/api/search?q=${query}`); +} +``` + +**Better**: Use framework's data fetching (React Query, SWR, Next.js, etc.) + +--- + +## Summary: When to Use What + +| Need | Solution | +|------|----------| +| Value from props/state | Calculate during render | +| Expensive calculation | `useMemo` | +| Reset all state on prop change | `key` prop | +| Respond to user action | Event handler | +| Sync with external system | `useEffect` with cleanup | +| Subscribe to external store | `useSyncExternalStore` | +| Share state between components | Lift state up | +| Fetch data | Custom hook with cleanup / framework | diff --git a/.github/skills/react-useeffect/anti-patterns.md b/.github/skills/react-useeffect/anti-patterns.md new file mode 100644 index 000000000000..d35151fdc8db --- /dev/null +++ b/.github/skills/react-useeffect/anti-patterns.md @@ -0,0 +1,290 @@ +# useEffect Anti-Patterns + +## 1. Redundant State for Derived Values + +```tsx +// BAD: Extra state + Effect for derived value +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); +} + +// GOOD: Calculate during rendering +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const fullName = firstName + ' ' + lastName; // Just compute it +} +``` + +**Why it's bad**: Causes extra render pass with stale value, then re-renders with updated value. + +--- + +## 2. Filtering/Transforming Data in Effect + +```tsx +// BAD: Effect to filter list +function TodoList({ todos, filter }) { + const [visibleTodos, setVisibleTodos] = useState([]); + + useEffect(() => { + setVisibleTodos(getFilteredTodos(todos, filter)); + }, [todos, filter]); +} + +// GOOD: Filter during render (memoize if expensive) +function TodoList({ todos, filter }) { + const visibleTodos = useMemo( + () => getFilteredTodos(todos, filter), + [todos, filter] + ); +} +``` + +--- + +## 3. Resetting State on Prop Change + +```tsx +// BAD: Effect to reset state +function ProfilePage({ userId }) { + const [comment, setComment] = useState(''); + + useEffect(() => { + setComment(''); + }, [userId]); +} + +// GOOD: Use key prop +function ProfilePage({ userId }) { + return ; +} + +function Profile({ userId }) { + const [comment, setComment] = useState(''); // Resets automatically +} +``` + +**Why key works**: React treats components with different keys as different components, recreating state. + +--- + +## 4. Event-Specific Logic in Effect + +```tsx +// BAD: Effect for button click result +function ProductPage({ product, addToCart }) { + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name}!`); + } + }, [product]); + + function handleBuyClick() { + addToCart(product); + } +} + +// GOOD: Handle in event handler +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + } +} +``` + +**Why it's bad**: Effect fires on page refresh (isInCart is true), showing notification unexpectedly. + +--- + +## 5. Chains of Effects + +```tsx +// BAD: Effects triggering each other +function Game() { + const [card, setCard] = useState(null); + const [goldCardCount, setGoldCardCount] = useState(0); + const [round, setRound] = useState(1); + const [isGameOver, setIsGameOver] = useState(false); + + useEffect(() => { + if (card?.gold) setGoldCardCount(c => c + 1); + }, [card]); + + useEffect(() => { + if (goldCardCount > 3) { + setRound(r => r + 1); + setGoldCardCount(0); + } + }, [goldCardCount]); + + useEffect(() => { + if (round > 5) setIsGameOver(true); + }, [round]); +} + +// GOOD: Calculate in event handler +function Game() { + const [card, setCard] = useState(null); + const [goldCardCount, setGoldCardCount] = useState(0); + const [round, setRound] = useState(1); + const isGameOver = round > 5; // Derived! + + function handlePlaceCard(nextCard) { + if (isGameOver) throw Error('Game ended'); + + setCard(nextCard); + if (nextCard.gold) { + if (goldCardCount < 3) { + setGoldCardCount(goldCardCount + 1); + } else { + setGoldCardCount(0); + setRound(round + 1); + if (round === 5) alert('Good game!'); + } + } + } +} +``` + +**Why it's bad**: Multiple re-renders (setCard -> setGoldCardCount -> setRound -> setIsGameOver). Also fragile for features like history replay. + +--- + +## 6. Notifying Parent via Effect + +```tsx +// BAD: Effect to notify parent +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + + useEffect(() => { + onChange(isOn); + }, [isOn, onChange]); + + function handleClick() { + setIsOn(!isOn); + } +} + +// GOOD: Notify in same event +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + + function updateToggle(nextIsOn) { + setIsOn(nextIsOn); + onChange(nextIsOn); // Same event, batched render + } + + function handleClick() { + updateToggle(!isOn); + } +} + +// BEST: Fully controlled component +function Toggle({ isOn, onChange }) { + function handleClick() { + onChange(!isOn); + } +} +``` + +--- + +## 7. Passing Data Up to Parent + +```tsx +// BAD: Child fetches, passes up via Effect +function Parent() { + const [data, setData] = useState(null); + return ; +} + +function Child({ onFetched }) { + const data = useSomeAPI(); + + useEffect(() => { + if (data) onFetched(data); + }, [onFetched, data]); +} + +// GOOD: Parent fetches, passes down +function Parent() { + const data = useSomeAPI(); + return ; +} +``` + +**Why**: Data should flow down. Upward flow via Effects makes debugging hard. + +--- + +## 8. Fetching Without Cleanup (Race Condition) + +```tsx +// BAD: No cleanup - race condition +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + fetchResults(query).then(json => { + setResults(json); // "hello" response may arrive after "hell" + }); + }, [query]); +} + +// GOOD: Cleanup ignores stale responses +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + let ignore = false; + + fetchResults(query).then(json => { + if (!ignore) setResults(json); + }); + + return () => { ignore = true; }; + }, [query]); +} +``` + +--- + +## 9. App Initialization in Effect + +```tsx +// BAD: Runs twice in dev, may break auth +function App() { + useEffect(() => { + loadDataFromLocalStorage(); + checkAuthToken(); // May invalidate token on second call! + }, []); +} + +// GOOD: Module-level guard +let didInit = false; + +function App() { + useEffect(() => { + if (!didInit) { + didInit = true; + loadDataFromLocalStorage(); + checkAuthToken(); + } + }, []); +} + +// ALSO GOOD: Module-level execution +if (typeof window !== 'undefined') { + checkAuthToken(); + loadDataFromLocalStorage(); +} +``` diff --git a/.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/.gitignore b/.gitignore index 60a48e184ee6..b12e32ca1abe 100644 --- a/.gitignore +++ b/.gitignore @@ -199,3 +199,5 @@ src/BloomBrowserUI/react_components/component-tester/test-results/ src/BloomBrowserUI/test-results/ critiqueAI.json + +*.stackdump diff --git a/AGENTS.md b/AGENTS.md index 023f84f1b76d..15fe61cce73a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,3 +43,9 @@ It is vital that you not run `yarn build` unless instructed to. If there is alre - Localizations for translatable strings are kept in DistFiles/localizations; new ones are initially added to one of the files in the "en" subdirectory - Mark new XLF entries translate="no" - Don't change the content or ID of an existing XLF entry unless it is new (marked translate="no"). Instead, mark the old one with a note saying it is "obsolete as of " and make a new entry with a different ID. + +# Commenting +All public methods should have a comment. So should most private ones! + +# Git Committing +Always include a good description when creating a git commit. diff --git a/DistFiles/localization/en/Bloom.xlf b/DistFiles/localization/en/Bloom.xlf index 91a0aed7fb16..dfcd47828ea1 100644 --- a/DistFiles/localization/en/Bloom.xlf +++ b/DistFiles/localization/en/Bloom.xlf @@ -550,6 +550,10 @@ Settings ID: CollectionSettingsDialog.CollectionSettingsWindowTitle + + Collection Settings + ID: CollectionSettingsDialog.Title + Change... ID: CollectionSettingsDialog.LanguageTab.ChangeLanguageLink @@ -910,6 +914,11 @@ ID: ColorPicker.New A background color selection that enables a color picker. + + Sample Color + ID: ColorPicker.SampleColor + Tooltip text on the eyedropper button that samples a color from the page. + Book Settings ID: Common.BookSettings diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index 6e55ef939b95..73f64d2e34f9 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -731,6 +731,47 @@ Book Settings BookSettings.Title the heading of the dialog + Obsolete as of 6.4 + + + Book and Page Settings + ID: BookAndPageSettings.Title + the heading of the dialog + + + Book + ID: BookAndPageSettings.BookArea + Area label for tabs/pages that affect all pages in the current book. + + + Book settings apply to all of the pages of the current book. + ID: BookAndPageSettings.BookArea.Description + Description text shown for the Book area in the combined Book and Page Settings dialog. + + + Current 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 @@ -814,6 +855,21 @@ Background Color Common.CoverBackgroundColor + + Outline Color + ID: PageSettings.OutlineColor + Label for the page number outline color control on the page Colors settings page. + + + Use an outline color when the page number needs more contrast against the page. + ID: PageSettings.PageNumberOutlineColor.Description + Help text for the page number outline color control on the page Colors settings page. + + + Use a page number background color when the theme puts the number inside a shape, for example a circle, and you want to specify the color of that shape. + ID: PageSettings.PageNumberBackgroundColor.Description + Help text for the page number background color control on the page Colors settings page. + Front Cover BookSettings.WhatToShowOnCover @@ -987,7 +1043,7 @@ Unnumbered Page ID: BookSettings.Fonts.UnnumberedPage - + Customized Front and Back Matter Page Layout ID: PageLayout.CustomXMatterPage diff --git a/scripts/watchBloomExe.mjs b/scripts/watchBloomExe.mjs index 35a4ab603bc7..0b583c999bee 100644 --- a/scripts/watchBloomExe.mjs +++ b/scripts/watchBloomExe.mjs @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { + findRunningStandardBloomInstances, requireOptionValue, requireTcpPortOption, } from "../.github/skills/bloom-automation/bloomProcessCommon.mjs"; @@ -83,6 +84,42 @@ if (!existsSync(projectPath)) { process.exit(1); } +const normalizeComparablePath = (value) => + path.resolve(value).replace(/\//g, "\\").toLowerCase(); + +const tryInferVitePortFromRunningBloom = async () => { + const runningInstances = await findRunningStandardBloomInstances(); + const expectedRepoRoot = normalizeComparablePath(options.repoRoot); + const vitePorts = [ + ...new Set( + runningInstances + .filter( + (instance) => + instance.detectedRepoRoot && + normalizeComparablePath(instance.detectedRepoRoot) === + expectedRepoRoot && + instance.vitePort, + ) + .map((instance) => instance.vitePort), + ), + ]; + + if (vitePorts.length === 1) { + return vitePorts[0]; + } + + if (vitePorts.length > 1) { + console.warn( + `Multiple running Bloom instances from this worktree reported different Vite ports (${vitePorts.join(", ")}). Launching without an inherited Vite port.`, + ); + } + + return undefined; +}; + +const effectiveVitePort = + options.vitePort ?? (await tryInferVitePortFromRunningBloom()); + const dotnetArgs = [ "watch", "run", @@ -98,12 +135,18 @@ if (startupLabel) { dotnetArgs.push("--label", startupLabel); } -if (options.vitePort) { - dotnetArgs.push("--vite-port", String(options.vitePort)); +if (effectiveVitePort) { + dotnetArgs.push("--vite-port", String(effectiveVitePort)); } -if (options.vitePort) { - console.log(`Bloom Vite dev port: ${options.vitePort}`); +if (effectiveVitePort) { + if (options.vitePort) { + console.log(`Bloom Vite dev port: ${effectiveVitePort}`); + } else { + console.log( + `Inherited Bloom Vite dev port from running worktree instance: ${effectiveVitePort}`, + ); + } } const createForwardingLineWriter = (target, onLine) => { diff --git a/src/BloomBrowserUI/AGENTS.md b/src/BloomBrowserUI/AGENTS.md index 0a5ec0663e44..b30c51f8e02e 100644 --- a/src/BloomBrowserUI/AGENTS.md +++ b/src/BloomBrowserUI/AGENTS.md @@ -35,39 +35,10 @@ When working in the front-end, cd to src/BloomBrowserUI ## About React useEffect -Rule 1 — Use useEffect when synchronizing with external systems: -Subscriptions, timers, or event listeners. +See {repository root}/.github/skills/react-useeffect -API calls or other asynchronous external operations. - -Updates to things outside React control (e.g., document.title, localStorage). - -Any side effect that cannot be computed during render. - -Rule 2 — Avoid useEffect when data can be derived or handled internally: - -State can be derived from props, context, or other state — compute in render. - -User interactions can be handled directly in event handlers. - -Local state reset/initialization can be handled by component keys or conditional rendering. - -Computed values can use useMemo or useCallback instead of syncing in an effect. - -Rule 3 — Validation heuristic: - -If removing the effect does not break external behavior, the effect is unnecessary. - -Implementation Tip for AI: - -Prefer pure render computation first. - -Add useEffect only when necessary for external side effects. - -Keep effects minimal and specific to their purpose; avoid overuse. - -Always include a comment before a useEffect explaining what it does and why it is necessary. +If you read that and decide that a useEffect is warranted, you must add a comment justifying why it is necessary. ## UI Tests diff --git a/src/BloomBrowserUI/bookEdit/StyleEditor/AudioHilitePage.tsx b/src/BloomBrowserUI/bookEdit/StyleEditor/AudioHilitePage.tsx index b48deadeb33a..5b09dc975e52 100644 --- a/src/BloomBrowserUI/bookEdit/StyleEditor/AudioHilitePage.tsx +++ b/src/BloomBrowserUI/bookEdit/StyleEditor/AudioHilitePage.tsx @@ -98,7 +98,7 @@ export const AudioHilitePage: React.FunctionComponent<{ initialColor={props.hiliteTextColor || props.color} localizedTitle={chooserTitleText} width={84} - transparency={false} + transparency={true} palette={BloomPalette.Text} onClose={(result, newColor) => props.onHilitePropsChanged( diff --git a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts index d4c136e5ca51..462340883269 100644 --- a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts +++ b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts @@ -2300,7 +2300,7 @@ export default class StyleEditor { onChange: (s: string) => void, ) { const colorPickerDialogProps: ISimpleColorPickerDialogProps = { - transparency: false, + transparency: true, localizedTitle: title, initialColor: initialColor, palette: BloomPalette.Text, diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.saving.test.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.saving.test.tsx new file mode 100644 index 000000000000..ef58d8abddcc --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.saving.test.tsx @@ -0,0 +1,273 @@ +import * as React from "react"; +import ReactDOM from "react-dom"; +import { act } from "react-dom/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockPost, mockPostJson, mockCloseDialog, configrPaneRenderState } = + vi.hoisted(() => ({ + mockPost: vi.fn(), + mockPostJson: vi.fn(), + mockCloseDialog: vi.fn(), + configrPaneRenderState: { + lastInitialValues: undefined as + | { + appearance?: { cssThemeName?: string }; + page: { + backgroundColor: string; + pageNumberColor: string; + pageNumberOutlineColor: string; + pageNumberBackgroundColor: string; + }; + } + | undefined, + }, + })); + +vi.mock("../../utils/shared", () => ({ + getPageIframeBody: () => document.body, + getBloomPageElement: () => + document.body.querySelector(".bloom-page") as HTMLElement | null, + whenBloomPageIsReady: (callback: (page: HTMLElement) => void) => { + const page = document.body.querySelector(".bloom-page") as HTMLElement; + callback(page); + return () => {}; + }, +})); + +vi.mock("../../utils/bloomApi", async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + post: mockPost, + postJson: mockPostJson, + useApiBoolean: () => [true], + useApiObject: (endpoint: string, defaultValue: unknown) => { + if (endpoint === "book/settings/appearanceUIOptions") { + return { + themeNames: [ + { label: "Default", value: "default" }, + { + label: "Rounded Border", + value: "rounded-border-ebook", + }, + ], + }; + } + + if (endpoint === "book/settings/overrides") { + return defaultValue; + } + + return defaultValue; + }, + useApiStringState: () => [ + JSON.stringify({ appearance: { cssThemeName: "default" } }), + ], + }; +}); + +vi.mock("../../react_components/l10nHooks", () => ({ + useL10n: (englishText: string) => englishText, +})); + +vi.mock("../../react_components/featureStatus", () => ({ + useGetFeatureStatus: () => ({ enabled: true }), +})); + +vi.mock("../../react_components/BloomDialog/BloomDialogPlumbing", () => ({ + useSetupBloomDialog: () => ({ + closeDialog: mockCloseDialog, + propsForBloomDialog: { open: true }, + }), +})); + +vi.mock("../../react_components/BloomDialog/BloomDialog", () => { + const MockBloomDialog = React.forwardRef< + HTMLDivElement, + React.PropsWithChildren + >((props, ref) =>
{props.children}
); + MockBloomDialog.displayName = "MockBloomDialog"; + + return { + BloomDialog: MockBloomDialog, + DialogBottomButtons: (props: React.PropsWithChildren) => ( +
{props.children}
+ ), + DialogMiddle: (props: React.PropsWithChildren) => ( +
{props.children}
+ ), + DialogTitle: (props: { title: string }) =>
{props.title}
, + }; +}); + +vi.mock("../../react_components/BloomDialog/commonDialogComponents", () => ({ + DialogOkButton: (props: { onClick: () => void }) => ( + + ), + DialogCancelButton: () => , +})); + +vi.mock("@sillsdev/config-r", () => ({ + ConfigrPane: (props: { + children: React.ReactNode; + initialValues: { + appearance?: { cssThemeName?: string }; + page: { + backgroundColor: string; + pageNumberColor: string; + pageNumberOutlineColor: string; + pageNumberBackgroundColor: string; + }; + }; + onChange: (settings: unknown) => void; + }) => { + configrPaneRenderState.lastInitialValues = props.initialValues; + return ( +
+ + + +
+ ); + }, + ConfigrArea: (props: React.PropsWithChildren) => ( +
{props.children}
+ ), + ConfigrPage: (props: React.PropsWithChildren) => ( +
{props.children}
+ ), + ConfigrGroup: (props: React.PropsWithChildren) => ( +
{props.children}
+ ), + ConfigrSelect: () => null, + ConfigrBoolean: () => null, + ConfigrStatic: (props: React.PropsWithChildren) => ( +
{props.children}
+ ), + ConfigrCustomStringInput: () => null, + ConfigrCustomObjectInput: () => null, +})); + +import { BookAndPageSettingsDialog } from "./BookAndPageSettingsDialog"; + +describe("BookAndPageSettingsDialog saving", () => { + let container: HTMLDivElement; + + const click = (selector: string) => { + const button = container.querySelector(selector) as HTMLButtonElement; + expect(button).not.toBeNull(); + act(() => { + button.click(); + }); + }; + + const renderDialog = async () => { + await act(async () => { + ReactDOM.render(, container); + }); + }; + + beforeEach(() => { + container = document.createElement("div"); + document.body.innerHTML = + '
'; + document.body.appendChild(container); + configrPaneRenderState.lastInitialValues = undefined; + mockPost.mockReset(); + mockPostJson.mockReset(); + mockCloseDialog.mockReset(); + }); + + afterEach(() => { + ReactDOM.unmountComponentAtNode(container); + container.remove(); + document.body.innerHTML = ""; + }); + + it("does not lock in untouched page values when only book settings change", async () => { + await renderDialog(); + + click('[data-testid="theme-change"]'); + click('[data-testid="dialog-ok"]'); + + const page = document.body.querySelector(".bloom-page") as HTMLElement; + + expect( + page.style.getPropertyValue("--marginBox-background-color"), + ).toBe(""); + expect(page.style.getPropertyValue("--page-background-color")).toBe(""); + expect(page.style.getPropertyValue("--pageNumber-color")).toBe(""); + expect(page.style.getPropertyValue("--pageNumber-outline-color")).toBe( + "", + ); + expect( + page.style.getPropertyValue("--pageNumber-background-color"), + ).toBe(""); + expect(mockPostJson).toHaveBeenCalledWith("book/settings", { + appearance: { cssThemeName: "rounded-border-ebook" }, + }); + }); + + it("does not save a page value that was changed back to its original value", async () => { + await renderDialog(); + + click('[data-testid="page-change"]'); + click('[data-testid="page-reset"]'); + click('[data-testid="dialog-ok"]'); + + const page = document.body.querySelector(".bloom-page") as HTMLElement; + + expect( + page.style.getPropertyValue("--marginBox-background-color"), + ).toBe(""); + expect(page.style.getPropertyValue("--page-background-color")).toBe(""); + expect(page.style.getPropertyValue("--pageNumber-color")).toBe(""); + expect(page.style.getPropertyValue("--pageNumber-outline-color")).toBe( + "", + ); + expect( + page.style.getPropertyValue("--pageNumber-background-color"), + ).toBe(""); + }); +}); diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.test.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.test.tsx new file mode 100644 index 000000000000..6a6f93f63453 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.test.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; +import ReactDOM from "react-dom"; +import { + ConfigrArea, + ConfigrPane, + ConfigrPage, + ConfigrStatic, +} from "@sillsdev/config-r"; +import { afterEach, describe, expect, it } from "vitest"; + +let renderedContainer: HTMLDivElement | undefined; + +function renderSettingsPane( + initiallySelectedTopLevelPageKey: string, +): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + + ReactDOM.render( + + + + +
Cover content
+
+
+ + +
Content pages content
+
+
+
+ + + +
Colors content
+
+
+
+
, + container, + ); + + return container; +} + +function getSelectedTabLabels(): string[] { + return Array.from( + document.querySelectorAll('[role="tab"][aria-selected="true"]'), + ).map((tab) => tab.textContent ?? ""); +} + +afterEach(() => { + if (renderedContainer) { + ReactDOM.unmountComponentAtNode(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; +}); + +describe("ConfigrPane nested initial selection", () => { + it.each([ + ["cover", "Cover"], + ["contentPages", "Content Pages"], + ["colors", "Colors"], + ])( + "shows nested page %s when passed as initiallySelectedTopLevelPageKey", + (pageKey, expectedLabel) => { + renderSettingsPane(pageKey); + + expect(getSelectedTabLabels()).toContain(expectedLabel); + }, + ); +}); diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx new file mode 100644 index 000000000000..6607a9b43859 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.tsx @@ -0,0 +1,517 @@ +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 { whenBloomPageIsReady } from "../../utils/shared"; +import { useL10n } from "../../react_components/l10nHooks"; +import { getWorkspaceBundleExports } from "../js/workspaceFrames"; +import { ElementAttributeSnapshot } from "../../utils/ElementAttributeSnapshot"; +import { useGetFeatureStatus } from "../../react_components/featureStatus"; +import { + applyChangedPageSettings, + getCurrentPageElement, + getChangedPageSettings, + getCurrentPageSettings, + IPageSettings, + parsePageSettingsFromConfigrValue, + usePageSettingsAreaDefinition, +} from "./PageSettingsConfigrPages"; +import { isLegacyThemeName } from "./appearanceThemeUtils"; +import { useBookSettingsAreaDefinition } from "./BookSettingsConfigrPages"; + +let isOpenAlready = false; +const kBookSettingsDialogWidthPx = 900; +const kBookSettingsDialogHeightPx = 720; +const kConfigrPaneClassName = "book-page-settings-configr-pane"; + +type IPageStyle = { label: string; value: string }; +type IPageStyles = Array; +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: Record; + xmatter: Record; + 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 = overrideInformation?.xmatter?.[subPath] as + | T + | undefined; + const brandingOverride = overrideInformation?.branding?.[subPath] as + | T + | undefined; + const override = xmatterOverride ?? brandingOverride; + // nb: xmatterOverride can be boolean, hence the need to spell out !==undefined + let description = + 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 [pageSettings, setPageSettings] = React.useState< + IPageSettings | undefined + >(undefined); + const [currentPageIsXMatter, setCurrentPageIsXMatter] = + React.useState(false); + + const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< + ConfigrValues | undefined + >(undefined); + const latestSettingsRef = React.useRef( + undefined, + ); + const dialogRef = React.useRef(null); + + const setDialogVisibleWhileColorPickerOpen = React.useCallback( + (open: boolean) => { + const dialogRoot = dialogRef.current?.closest(".MuiDialog-root"); + if (!(dialogRoot instanceof HTMLElement)) { + return; + } + if (open) { + dialogRoot.style.visibility = "hidden"; + dialogRoot.style.pointerEvents = "none"; + } else { + dialogRoot.style.visibility = ""; + dialogRoot.style.pointerEvents = ""; + } + }, + [], + ); + + const removePageSettingsFromConfigrSettings = ( + settingsValue: ConfigrValues, + ): IBookSettings => { + const settingsWithoutPage = { + ...settingsValue, + } as Record; + delete settingsWithoutPage["page"]; + return settingsWithoutPage as IBookSettings; + }; + + const settings: IBookSettings | undefined = React.useMemo(() => { + if (settingsString === "{}") { + return undefined; + } + if (typeof settingsString === "string") { + return JSON.parse(settingsString) as IBookSettings; + } + return settingsString as unknown as IBookSettings; + }, [settingsString]); + + const configrInitialValues: ConfigrValues | undefined = + React.useMemo(() => { + if (!settings || !pageSettings) { + return undefined; + } + + return { + ...settings, + page: pageSettings.page, + } as unknown as ConfigrValues; + }, [settings, pageSettings]); + + const [deletedCustomBookStyles, setDeletedCustomBookStyles] = + React.useState(false); + + const initialPageAttributeSnapshot = React.useRef< + ElementAttributeSnapshot | undefined + >(undefined); + + // This effect synchronizes dialog state with the editable page iframe, which is outside React. + // The iframe body can exist slightly before .bloom-page is inserted, so wait for that element + // once on mount before snapshotting settings used by Cancel and the initial config-r values. + React.useEffect(() => { + return whenBloomPageIsReady((currentPageElement) => { + setPageSettings(getCurrentPageSettings()); + initialPageAttributeSnapshot.current = + ElementAttributeSnapshot.fromElement(currentPageElement); + setCurrentPageIsXMatter( + currentPageElement.classList.contains("bloom-frontMatter") || + currentPageElement.classList.contains("bloom-backMatter"), + ); + }); + }, []); + + // If the dialog unmounts while a nested color picker is open, clear the shared visibility flag + // so the parent dialog does not stay hidden after this component is gone. + React.useEffect(() => { + return () => { + setDialogVisibleWhileColorPickerOpen(false); + }; + }, [setDialogVisibleWhileColorPickerOpen]); + + const bookSettingsTitle = useL10n( + "Book and Page Settings", + "BookAndPageSettings.Title", + ); + + const firstPossiblyLegacyCss = deletedCustomBookStyles + ? "" + : (appearanceUIOptions?.firstPossiblyLegacyCss ?? ""); + const migratedTheme = deletedCustomBookStyles + ? "" + : (appearanceUIOptions?.migratedTheme ?? ""); + const liveAppearance = + (settingsToReturnLater?.["appearance"] as + | IAppearanceSettings + | undefined) ?? settings?.appearance; + const appearanceDisabled = isLegacyThemeName(liveAppearance?.cssThemeName); + + // We keep theme as a render-time value from the latest working settings so the dialog reflects + // Configr edits immediately without a second state synchronization layer. + const theme = liveAppearance?.cssThemeName ?? ""; + + const deleteCustomBookStyles = () => { + post( + `book/settings/deleteCustomBookStyles?file=${firstPossiblyLegacyCss}`, + ); + setDeletedCustomBookStyles(true); + }; + + const tierAllowsFullBleed = useGetFeatureStatus("PrintshopReady")?.enabled; + + const closeDialogAndClearOpenFlag = React.useCallback(() => { + latestSettingsRef.current = undefined; + isOpenAlready = false; + closeDialog(); + }, [closeDialog]); + + const cancelAndCloseDialog = React.useCallback(() => { + try { + if (initialPageAttributeSnapshot.current) { + initialPageAttributeSnapshot.current.restoreToElement( + getCurrentPageElement(), + ); + } + } finally { + closeDialogAndClearOpenFlag(); + } + }, [closeDialogAndClearOpenFlag]); + + function saveSettingsAndCloseDialog() { + try { + const latestSettings = + latestSettingsRef.current ?? settingsToReturnLater; + if (latestSettings) { + const parsedPageSettings = + parsePageSettingsFromConfigrValue(latestSettings); + const changedPageSettings = pageSettings + ? getChangedPageSettings(pageSettings, parsedPageSettings) + : undefined; + + if (changedPageSettings) { + applyChangedPageSettings(changedPageSettings); + } + + const settingsToPost = + removePageSettingsFromConfigrSettings(latestSettings); + // If nothing changed, we don't get any...and don't need to make this call. + postJson("book/settings", settingsToPost); + } + } finally { + closeDialogAndClearOpenFlag(); + } + // 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, + tierAllowsFullBleed, + pageSizeSupportsFullBleed, + settings, + settingsToReturnLater, + getAdditionalProps, + firstPossiblyLegacyCss, + theme, + migratedTheme, + deleteCustomBookStyles, + saveSettingsAndCloseDialog, + onColorPickerVisibilityChanged: setDialogVisibleWhileColorPickerOpen, + themeNames: appearanceUIOptions.themeNames, + }); + + const pageSettingsArea = usePageSettingsAreaDefinition({ + onColorPickerVisibilityChanged: setDialogVisibleWhileColorPickerOpen, + }); + + const initiallySelectedConfigrPageKey = currentPageIsXMatter + ? undefined + : props.initiallySelectedPageKey; + + const configrAreas = [ + + {bookSettingsArea.pages} + , + ]; + + if (!currentPageIsXMatter) { + configrAreas.push( + + {pageSettingsArea.pages} + , + ); + } + + return ( + cancelAndCloseDialog()} + onCancel={() => cancelAndCloseDialog()} + draggable={false} + maxWidth={false} + > + + + {configrInitialValues && ( + { + const parsedPageSettings = + parsePageSettingsFromConfigrValue(s); + const changedPageSettings = pageSettings + ? getChangedPageSettings( + pageSettings, + parsedPageSettings, + ) + : undefined; + + // Config-r may call onChange while rendering, so defer state updates. + latestSettingsRef.current = s; + window.setTimeout(() => { + setSettingsToReturnLater(s); + }, 0); + + if ( + !pageSettings || + !initialPageAttributeSnapshot.current + ) { + return; + } + + initialPageAttributeSnapshot.current.restoreToElement( + getCurrentPageElement(), + ); + + if (!changedPageSettings) { + return; + } + + applyChangedPageSettings(changedPageSettings); + }} + initiallySelectedTopLevelPageKey={ + initiallySelectedConfigrPageKey + } + > + {configrAreas} + + )} + + + + + + + ); +}; + +export function showBookSettingsDialog(initiallySelectedPageKey?: string) { + // once Bloom's tab bar is also in react, it won't be possible + // to open another copy of this without closing it first, but + // for now, we need to prevent that. + if (!isOpenAlready) { + isOpenAlready = true; + try { + getWorkspaceBundleExports().ShowEditViewDialog( + , + ); + } catch (error) { + isOpenAlready = false; + throw error; + } + } +} diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx new file mode 100644 index 000000000000..e7645f915dde --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookSettingsConfigrPages.tsx @@ -0,0 +1,737 @@ +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 { isLegacyThemeName } from "./appearanceThemeUtils"; +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; + 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 legacyTheme = isLegacyThemeName(props.theme); + 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 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. +
+
+
+ )} + + + + ( + `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 && legacyTheme && ( + + + + + + )} + {props.firstPossiblyLegacyCss === "customBookStyles.css" && + !legacyTheme && ( + + +
+ {props.migratedTheme ? ( + + ) : ( + + )} +
+ props.deleteCustomBookStyles() + } + > + +
+ Delete{" "} + {props.firstPossiblyLegacyCss} +
+
+
+
+
+ )} + {props.firstPossiblyLegacyCss.length > 0 && + props.firstPossiblyLegacyCss !== "customBookStyles.css" && + !legacyTheme && ( + + + + + + )} + + {/* Wrapping these two in a div prevents Config-R from sticking a divider between them */} +
+ { + return { + label: x.label, + value: x.value, + }; + })} + description={themeDescription} + /> + {props.appearanceDisabled && ( + +
+ The selected page theme does not support the + following settings. +
+
+ )} +
+ ( + `pageNumber-position`, + )} + options={[ + { + label: pageNumberPositionAutomaticLabel, + value: PageNumberPosition.Automatic, + }, + { + label: pageNumberPositionLeftLabel, + value: PageNumberPosition.Left, + }, + { + label: pageNumberPositionCenterLabel, + value: PageNumberPosition.Center, + }, + { + label: pageNumberPositionRightLabel, + value: PageNumberPosition.Right, + }, + { + label: "--", + value: "--", + }, + { + label: pageNumberPositionHiddenLabel, + value: PageNumberPosition.Hidden, + }, + ]} + description={pageNumberLocationNote} + /> +
+ + + + + ( + `topLevel-text-padding`, + )} + /> + (`page-gutter`)} + /> + +
, + + +
+ (`fullBleed`)} + disabled={ + !props.tierAllowsFullBleed || + !props.pageSizeSupportsFullBleed + } + /> +
+ +
+
+
+
, + + {/* note that this is used for bloomPUB and ePUB, but we don't have separate settings so we're putting them in bloomPUB and leaving it to c# code to use it for ePUB as well. */} + + + + , + + + + +
+

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

+

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

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

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

-

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

-
-
- -
-
-
-
- )} -
- - - - -
- ); -}; - -type Resolution = { - maxWidth: number; - maxHeight: number; -}; - -const BloomResolutionSlider: React.FunctionComponent< - React.PropsWithChildren<{ - path: string; - label: string; - }> -> = (props) => { - return ( -
- - control={BloomResolutionSliderInner} - {...props} - > -
- Bloom reduces images to a maximum size to make books easier to - view over poor internet connections and take up less space on - phones. -
-
- ); -}; - -const BloomResolutionSliderInner: React.FunctionComponent<{ - value: Resolution; - onChange: (value: Resolution) => void; -}> = (props) => { - const sizes = [ - { l: "Small", w: 600, h: 600 }, - { l: "HD", w: 1280, h: 720 }, - { l: "Full HD", w: 1920, h: 1080 }, - { l: "4K", w: 3840, h: 2160 }, - ]; - let currentIndex = sizes.findIndex((x) => x.w === props.value.maxWidth); - if (currentIndex === -1) { - currentIndex = 1; // See BL-12803. - } - const current = sizes[currentIndex]; - const currentLabel = useL10n( - current.l, - `BookSettings.eBook.Image.MaxResolution.${current.l}`, - ); - - return ( - -
- {`${currentLabel}`} - { - return `${current.w}x${current.h}`; - }} - onChange={(e, value) => { - props.onChange({ - maxWidth: sizes[value as number].w, - maxHeight: sizes[value as number].h, - }); - }} - valueLabelDisplay="auto" - > -
-
- ); -}; - -export function showBookSettingsDialog(initiallySelectedGroupIndex?: number) { - // once Bloom's tab bar is also in react, it won't be possible - // to open another copy of this without closing it first, but - // for now, we need to prevent that. - if (!isOpenAlready) { - isOpenAlready = true; - ShowEditViewDialog( - , - ); - } -} - -export const MessageUsingLegacyThemeWithIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The {0} stylesheet of this book is incompatible with modern themes. - Bloom is using it because the book is using the Legacy-5-6 theme. - Click [here] for more information. - - ); -}; - -export const MessageUsingMigratedThemeInsteadOfIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( -
- Bloom found a known version of {props.fileName} in this book and - replaced it with a modern theme. You can delete it unless you still - need to publish the book from an earlier version of Bloom. -
- ); -}; - -export const MessageIgnoringIncompatibleCssCanDelete: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The - {props.fileName} stylesheet of this book is incompatible with modern - themes. Bloom is currently ignoring it. If you don't need those - customizations any more, you can delete your - {props.fileName}. Click [here] for more information. - - ); -}; -export const MessageIgnoringIncompatibleCss: React.FunctionComponent<{ - fileName: string; - className?: string; -}> = (props) => { - return ( - - The {props.fileName} stylesheet of this book is incompatible with - modern themes. Bloom is currently ignoring it. Click [here] for more - information. - - ); -}; - -const ColorPickerForConfigr: React.FunctionComponent<{ - value: string; - disabled: boolean; - onChange: (value: string) => void; -}> = (props) => { - const coverBackgroundColorLabel = useL10n( - "Background Color", - "Common.BackgroundColor", - ); - - return ( - { - if (dialogResult === DialogResult.OK) props.onChange(newColor); - }} - /> - ); -}; diff --git a/src/BloomBrowserUI/bookEdit/css/editMode.less b/src/BloomBrowserUI/bookEdit/css/editMode.less index e81d483073d2..671899fa8208 100644 --- a/src/BloomBrowserUI/bookEdit/css/editMode.less +++ b/src/BloomBrowserUI/bookEdit/css/editMode.less @@ -351,6 +351,10 @@ body:has(#canvas-element-context-controls:hover) .bloom-page, left: 5mm; top: @PageLabelVerticalDisplacement; float: left; + font-family: @UIFontStack; + font-size: 9pt; + line-height: 16px; + font-weight: 400; &[contenteditable="true"] { color: @ControlColor; @@ -560,7 +564,7 @@ body.bloom-fullBleed { // to the code that tries to keep the background image and canvas elements aligned. .imageButton, .split-pane-divider, - .origami-toggle { + .change-layout-mode-toggle { display: none !important; } @@ -1326,10 +1330,8 @@ body.hideAllCKEditors .cke_chrome { } // ----- Bloom Games ---- -// Tweak the position of the Start/Correct/Wrong/Play control +// Width is handled here; top-strip alignment is controlled alongside the other above-page controls. #drag-activity-tab-control { - position: relative; - top: -8px; width: 100%; } diff --git a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less index 308d3e85bd86..6c715ce734cf 100644 --- a/src/BloomBrowserUI/bookEdit/css/origamiEditing.less +++ b/src/BloomBrowserUI/bookEdit/css/origamiEditing.less @@ -163,13 +163,32 @@ top: @ToggleVerticalOffset; width: 100%; display: flex; - justify-content: end; + align-items: center; + justify-content: space-between; box-sizing: border-box; } -.origami-toggle { + +#drag-activity-tab-control { + display: flex; + align-items: center; + height: 100%; + margin-left: auto; +} + +.above-page-control-typography { + font-family: @UIFontStack; + font-size: 9pt !important; + line-height: 16px !important; + font-weight: 400 !important; +} + +.change-layout-mode-toggle { + display: flex; + align-items: center; + gap: 6px; cursor: pointer; + margin-top: 2px; margin-right: 19px; - line-height: 1em; color: @DisabledColor; div { color: @ActiveSwitchColor; @@ -179,6 +198,41 @@ } } +// .page-settings-button { +// display: flex; +// align-items: center; +// justify-content: flex-start; +// gap: 6px; +// width: auto; +// height: 24px; +// padding: 0 4px; +// margin-right: 8px; +// border: none; +// background-color: transparent; +// cursor: pointer; +// color: @bloom-purple; +// white-space: nowrap; +// font-family: inherit; +// font-size: inherit; +// line-height: inherit; +// font-weight: inherit; + +// &:hover { +// opacity: 0.8; +// } + +// svg { +// width: 20px; +// height: 20px; +// flex-shrink: 0; +// align-self: center; +// } + +// .page-settings-button-label { +// white-space: nowrap; +// } +// } + // here follows the inner workings of the toggle .onoffswitch { display: inline-block; diff --git a/src/BloomBrowserUI/bookEdit/editablePage.ts b/src/BloomBrowserUI/bookEdit/editablePage.ts index ddaa66a9c881..e346a68cc183 100644 --- a/src/BloomBrowserUI/bookEdit/editablePage.ts +++ b/src/BloomBrowserUI/bookEdit/editablePage.ts @@ -15,7 +15,7 @@ import { theOneCanvasElementManager, CanvasElementManager, } from "./js/CanvasElementManager"; -import { renderDragActivityTabControl } from "./toolbox/games/DragActivityTabControl"; +import { renderDragActivityTabControl } from "./js/AbovePageControls"; function getPageId(): string { const page = document.querySelector(".bloom-page"); diff --git a/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx new file mode 100644 index 000000000000..dd0f7228bd33 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx @@ -0,0 +1,257 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { useL10n } from "../../react_components/l10nHooks"; +import { CustomPageLayoutMenu } from "../toolbox/canvas/customPageLayoutMenu"; +import { + DragActivityTabControl, + kIdForDragActivityTabControl, +} from "../toolbox/games/DragActivityTabControl"; +import { CogIcon } from "./CogIcon"; +import { getWorkspaceBundleExports } from "./workspaceFrames"; + +interface IAbovePageControlsState { + isGamePage: boolean; + activeGameTab: number; + showChangeLayoutModeToggle: boolean; + isChangeLayoutMode: boolean; + onChangeLayoutModeToggle?: () => void; + showPageLayoutMenu: boolean; + isCustomPageLayout: boolean; + disableCustomPage?: boolean; + onSetCustom?: ( + value: "standard" | "custom", + keepCustomLayoutDataWhenSwitchingToStandard: boolean, + ) => void; +} + +const defaultState: IAbovePageControlsState = { + isGamePage: false, + activeGameTab: 0, + showChangeLayoutModeToggle: false, + isChangeLayoutMode: false, + showPageLayoutMenu: false, + isCustomPageLayout: false, +}; + +let currentState: IAbovePageControlsState = defaultState; + +export function updateAbovePageControls( + stateUpdate: Partial, +): void { + currentState = { + ...currentState, + ...stateUpdate, + }; + + renderAbovePageControls(); +} + +export function resetAbovePageControls(): void { + currentState = defaultState; + + const container = document.getElementsByClassName( + "above-page-control-container", + )[0] as HTMLElement | undefined; + if (container) { + ReactDOM.unmountComponentAtNode(container); + container.replaceChildren(); + } +} + +export function renderDragActivityTabControl(currentTab: number): void { + currentState = { + ...currentState, + activeGameTab: currentTab, + }; + + if (currentState.isGamePage) { + renderAbovePageControls(); + } +} + +function renderAbovePageControls(): void { + const page = document.getElementsByClassName("bloom-page")[0] as + | HTMLElement + | undefined; + if (!page) { + return; + } + + const container = getOrCreateAbovePageControlContainer(page); + if (!container) { + return; + } + + ReactDOM.render( + , + container, + ); +} + +function getOrCreateAbovePageControlContainer( + page: HTMLElement, +): HTMLElement | undefined { + let container = document.getElementsByClassName( + "above-page-control-container", + )[0] as HTMLElement | undefined; + + if (!container) { + container = document.createElement("div"); + container.classList.add("above-page-control-container"); + container.classList.add("bloom-ui"); + + const pageScalingContainer = document.getElementById( + "page-scaling-container", + ); + if (pageScalingContainer) { + pageScalingContainer.prepend(container); + } else { + page.parentElement?.insertBefore( + container, + page.parentElement.firstChild, + ); + } + } + + container.style.maxWidth = page.clientWidth + "px"; + return container; +} + +const AbovePageControls: React.FunctionComponent = ( + props, +) => { + if (props.isGamePage) { + return ( +
+
+ {/* */} +
+
+ +
+
+ ); + } + + return ( +
+
+ {/* */} +
+
+ {props.showChangeLayoutModeToggle && ( + + )} + {props.showPageLayoutMenu && props.onSetCustom && ( + + )} +
+
+ ); +}; + +// const PageSettingsButton: React.FunctionComponent = () => { +// const label = useL10n("Page Settings", "PageSettings.Title"); +// const title = useL10n("Open Page Settings...", "PageSettings.OpenTooltip"); + +// return ( +// +// ); +// }; + +const ChangeLayoutModeToggle: React.FunctionComponent<{ + isChecked: boolean; + onChange?: () => void; +}> = (props) => { + const label = useL10n("Change Layout", "EditTab.CustomPage.ChangeLayout"); + + return ( +
+
{label}
+
+ props.onChange?.()} + /> + +
+
+ ); +}; diff --git a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx index 419779d1c28e..7d9f46cc5618 100644 --- a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx @@ -73,7 +73,7 @@ import { } from "../toolbox/canvas/canvasElementUtils"; import { wrapWithRequestPageContentDelay } from "./bloomEditing"; import { get, post, useApiObject } from "../../utils/bloomApi"; -import { ILanguageNameValues } from "../bookSettings/FieldVisibilityGroup"; +import { ILanguageNameValues } from "../bookAndPageSettings/FieldVisibilityGroup"; import StyleEditor from "../StyleEditor/StyleEditor"; import OverflowChecker from "../OverflowChecker/OverflowChecker"; diff --git a/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx b/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx index 044060f94b4a..15e931719018 100644 --- a/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CogIcon.tsx @@ -1,8 +1,22 @@ import * as React from "react"; import { SvgIcon, SvgIconProps } from "@mui/material"; -export const CogIcon: React.FunctionComponent = (props) => ( - - - -); +export const CogIcon: React.FunctionComponent< + SvgIconProps & { size?: number | string } +> = (props) => { + const svgIconProps = { ...props }; + delete svgIconProps.size; + + return ( + + + + ); +}; diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts index 51e51fee1d06..efa216df4d1a 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts @@ -66,6 +66,7 @@ import PlaceholderProvider from "./PlaceholderProvider"; import { initChoiceWidgetsForEditing } from "./simpleComprehensionQuiz"; import { handleUndo } from "../workspaceRoot"; import { setupPageLayoutMenu } from "../toolbox/canvas/customXmatterPage"; +import { resetAbovePageControls } from "./AbovePageControls"; // Allows toolbox code to make an element properly in the context of this iframe. export function makeElement( @@ -1204,8 +1205,9 @@ export function localizeCkeditorTooltips(bar: JQuery) { // This is invoked when we are about to change pages. function removeEditingDebris() { - // We are mirroring the origami layoutToggleClickHandler() here, in case the user changes - // pages while the origami toggle in on. + resetAbovePageControls(); + // We are mirroring the Change Layout mode toggle behavior here, in case the user changes + // pages while the Change Layout mode toggle is on. // The DOM here is for just one page, so there's only ever one marginBox. const marginBox = document.getElementsByClassName("marginBox")[0]; marginBox.classList.remove("origami-layout-mode"); diff --git a/src/BloomBrowserUI/bookEdit/js/origami.ts b/src/BloomBrowserUI/bookEdit/js/origami.ts index 6da140e5121f..bece3863fe82 100644 --- a/src/BloomBrowserUI/bookEdit/js/origami.ts +++ b/src/BloomBrowserUI/bookEdit/js/origami.ts @@ -1,65 +1,55 @@ -// 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"; import TextBoxProperties from "../TextBoxProperties/TextBoxProperties"; -import { post, postThatMightNavigate } from "../../utils/bloomApi"; +import { postThatMightNavigate } from "../../utils/bloomApi"; import { theOneCanvasElementManager } from "./CanvasElementManager"; import { getFeatureStatusAsync } from "../../react_components/featureStatus"; import $ from "jquery"; +import "../../lib/jquery.i18n.custom"; import { splitPane } from "../../lib/split-pane/split-pane"; import { kCanvasToolId } from "../toolbox/toolIds"; +import { updateAbovePageControls } from "./AbovePageControls"; $(() => { splitPane($("div.split-pane")); }); +let isWidgetFeatureEnabledForOrigami = false; +let isCanvasFeatureEnabledForOrigami = false; + export function setupOrigami() { getFeatureStatusAsync("widget").then((widgetFeatureStatus) => { getFeatureStatusAsync("canvas").then((canvasFeatureStatus) => { - const isWidgetFeatureEnabled: boolean = + isWidgetFeatureEnabledForOrigami = widgetFeatureStatus?.enabled || false; - const isCanvasFeatureEnabled: boolean = + isCanvasFeatureEnabledForOrigami = canvasFeatureStatus?.enabled || false; + replaceOrigamiTemplates(); const customPages = document.getElementsByClassName("customPage"); - if (customPages.length > 0) { - const width = customPages[0].clientWidth; - const origamiControl = getAbovePageControlContainer() - .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); - // 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"; + const bloomPage = document.getElementsByClassName( + "bloom-page", + )[0] as HTMLElement | undefined; + if (bloomPage) { + const showChangeLayoutModeControls = customPages.length > 0; + updateAbovePageControls({ + isGamePage: + bloomPage?.getAttribute("data-tool-id") === "game", + showChangeLayoutModeToggle: showChangeLayoutModeControls, + isChangeLayoutMode: $(".marginBox").hasClass( + "origami-layout-mode", + ), + onChangeLayoutModeToggle: handleChangeLayoutModeToggle, + }); } // I'm not clear why the rest of this needs to wait until we have // the two results, but none of the controls shows up if we leave it all // outside the bloomApi functions. - $(".origami-toggle .onoffswitch").change(layoutToggleClickHandler); - if ($(".customPage .marginBox.origami-layout-mode").length) { setupLayoutMode(); - $("#myonoffswitch").prop("checked", true); } - $(".customPage, .above-page-control-container") - .find("*[data-i18n]") - .localize(); + $(".customPage").find("*[data-i18n]").localize(); }); }); } @@ -68,11 +58,43 @@ export function cleanupOrigami() { // Otherwise, we get a new one each time the page is loaded $(".split-pane-resize-shim").remove(); } -function isEmpty(el) { - const temp = $.trim(el[0].textContent); - //alert("-" + temp + "- equals empty string: " + (temp == "").toString()); - return temp === ""; + +function replaceOrigamiTemplates() { + $(".origami-template-container").remove(); + + const templateContainer = $( + "", + ); + templateContainer + .append( + createTypeSelectors( + isWidgetFeatureEnabledForOrigami, + isCanvasFeatureEnabledForOrigami, + ), + ) + .append(createTextBoxIdentifier()); + + const pageScalingContainer = document.getElementById( + "page-scaling-container", + ); + if (pageScalingContainer) { + $(pageScalingContainer).prepend(templateContainer); + } else { + $(".customPage").first().before(templateContainer); + } + + templateContainer.find("*[data-i18n]").localize(); +} + +function getRequiredOrigamiTemplate(selector: string) { + const template = $(".origami-template-container").find(selector).first(); + if (!template.length) { + throw new Error(`Missing origami template for ${selector}`); + } + + return template; } + function setupLayoutMode() { theOneCanvasElementManager.suspendComicEditing("forTool"); $(".split-pane-component-inner").each(function (): boolean { @@ -135,7 +157,7 @@ function doesSplitPaneComponentNeedTextBoxIdentifier(spci: JQuery) { return !spci.find(`${bloomContainerClasses} .selector-links`).length; } -function layoutToggleClickHandler() { +function changeLayoutModeToggleClickHandler() { const marginBox = $(".marginBox"); if (!marginBox.hasClass("origami-layout-mode")) { marginBox.addClass("origami-layout-mode"); @@ -156,7 +178,7 @@ function layoutToggleClickHandler() { marginBox.removeClass("origami-layout-mode"); marginBox.find(".textBox-identifier").remove(); origamiUndoStack.length = origamiUndoIndex = 0; - // delay further processing to avoid messing up origami toggle transition + // delay further processing to avoid messing up the Change Layout mode toggle transition // 400ms CSS toggle transition + 50ms extra to give it time to finish up. const toggleTransitionLength = 450; setTimeout(() => { @@ -166,6 +188,13 @@ function layoutToggleClickHandler() { } } +function handleChangeLayoutModeToggle() { + changeLayoutModeToggleClickHandler(); + updateAbovePageControls({ + isChangeLayoutMode: $(".marginBox").hasClass("origami-layout-mode"), + }); +} + function GetTextBoxPropertiesDialog() { return new TextBoxProperties("/bloom/bookEdit"); } @@ -217,7 +246,11 @@ function adjustModifiedChild(resizedElt: HTMLElement | undefined) { } } -const origamiUndoStack: any[] = []; +interface IOrigamiUndoItem { + original: JQuery; +} + +const origamiUndoStack: IOrigamiUndoItem[] = []; let origamiUndoIndex = 0; // of item that should be redone next, if any // Add a point to which the user can return using 'undo'. Call this before making any change that @@ -337,36 +370,6 @@ function getSplitPaneComponentInner() { spci.append(getButtons()); return spci; } - -function getAbovePageControlContainer(): JQuery { - // for dragActivities we don't want the origami control, but we still make the - // wrapper so that the dragActivity can put a different control in it. - // Note: We also have to disable the Choose Different layout option in - // the right click menu, in PageListView.cs - if ( - document - .getElementsByClassName("bloom-page")[0] - ?.getAttribute("data-tool-id") === "game" - ) { - return $("
"); - } - return $( - "\ -
\ -
\ -
Change Layout
\ -
\ - \ - \ -
\ -
\ -
", - ); -} - function getButtons() { const buttons = $( "
", @@ -470,10 +473,14 @@ function createTextBoxIdentifier() { ).append(textBoxId); } function getTypeSelectors() { - return $(".container-selector-links > .selector-links").clone(true); + return getRequiredOrigamiTemplate( + ".container-selector-links > .selector-links", + ).clone(true); } function getTextBoxIdentifier() { - return $(".container-textBox-id > .textBox-identifier").clone(); + return getRequiredOrigamiTemplate( + ".container-textBox-id > .textBox-identifier", + ).clone(); } function makeTextFieldClickHandler(e) { e.preventDefault(); diff --git a/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts b/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts index a1d6580db60e..77566eba697f 100644 --- a/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts +++ b/src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts @@ -13,9 +13,9 @@ to hide the details so that we can easily change it later. */ -import { IPageFrameExports } from "../editablePage"; -import { IWorkspaceExports } from "../workspaceRoot"; -import { IToolboxFrameExports } from "../toolbox/toolboxBootstrap"; +import type { IPageFrameExports } from "../editablePage"; +import type { IWorkspaceExports } from "../workspaceRoot"; +import type { IToolboxFrameExports } from "../toolbox/toolboxBootstrap"; export function getToolboxBundleExports(): IToolboxFrameExports | null { const frameWindow = getFrame("toolbox") as diff --git a/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx b/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx index add78f013d77..1254548ead88 100644 --- a/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx +++ b/src/BloomBrowserUI/bookEdit/pageThumbnailList/PageThumbnail.tsx @@ -44,6 +44,12 @@ export const PageThumbnail: React.FunctionComponent<{ // a fast desktop for a complex page...mainly because of XhtmlToHtml conversion. // So we do it lazily after setting up the initial framework of pages. const requestPage = useCallback(() => { + if (props.page.key === "placeholder") { + // Placeholder entries still go through the effect that increments the + // pending count, so balance it before skipping the request. + pendingPageRequestCount--; + return; + } // We don't want a lot of page requests running at the same time. // There are various limits on simultaneous requests, including // the number of threads in the BloomServer and the number of active diff --git a/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx b/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx index 60351ff3da45..1e142d514fba 100644 --- a/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx +++ b/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx @@ -78,6 +78,20 @@ interface IContextMenuPoint { pageId: string; } +// Thumbnails render only the page div, but many Bloom layout rules are written against +// body[data-*] selectors. Copy the book body's data-* attributes onto a wrapper here so +// those selectors still match in the thumbnail pane. Lowercase them first because React +// only forwards custom DOM attributes reliably when the prop name is lowercase. +const normalizeBookDisplayAttributes = ( + attributes: Record, +): Record => { + const normalized: Record = {}; + Object.entries(attributes).forEach(([key, value]) => { + normalized[key.startsWith("data-") ? key.toLowerCase() : key] = value; + }); + return normalized; +}; + // This map goes from page ID to a callback that we get from the page thumbnail // which should be called when the main Bloom program informs us that // the thumbnail needs to be updated. @@ -107,6 +121,11 @@ const PageList: React.FunctionComponent<{ pageLayout: string }> = (props) => { "pageList/bookAttributesThatMayAffectDisplay", {}, ); + const normalizedBookDisplayAttributes = React.useMemo( + () => + normalizeBookDisplayAttributes(bookAttributesThatMayAffectDisplay), + [bookAttributesThatMayAffectDisplay], + ); const pageMenuDefinition: IPageMenuItem[] = [ { @@ -464,10 +483,7 @@ const PageList: React.FunctionComponent<{ pageLayout: string }> = (props) => { }; return ( -
+
{ defaultButtonLabel?: string, ) => { const colorPickerDialogProps: IColorPickerDialogProps = { - transparency: false, + transparency: true, noGradientSwatches: true, localizedTitle, initialColor, diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx index 1e32a186d131..017fb408be94 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx @@ -1,7 +1,7 @@ import * as React from "react"; -import type { SelectChangeEvent } from "@mui/material/Select"; import { css, ThemeProvider } from "@emotion/react"; -import { Select } from "@mui/material"; +import { Button, Menu } from "@mui/material"; +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import { toolboxMenuPopupTheme } from "../../../bloomMaterialUITheme"; import { kBloomPurple } from "../../../utils/colorUtils"; import { useL10n } from "../../../react_components/l10nHooks"; @@ -15,6 +15,7 @@ export const CustomPageLayoutMenu: React.FunctionComponent<{ keepCustomLayoutDataWhenSwitchingToStandard: boolean, ) => void; }> = (props) => { + const [menuAnchor, setMenuAnchor] = React.useState(); const selectedLayoutLabel = useL10n( props.isCustom ? "Custom Layout" : "Standard Layout", props.isCustom @@ -22,20 +23,21 @@ export const CustomPageLayoutMenu: React.FunctionComponent<{ : "EditTab.CustomCover.StandardLayout", ); - const handleChange = (event: SelectChangeEvent) => { - const selection = event.target.value as "standard" | "custom"; - // TypeScript thinks the argument should be a SelectChangeEvent in order to pass - // the function as the onChange handler for a Select, but in fact it always - // comes in as a PointerEvent which has the keyboard modifier info we need. - const nativeEvent = (event as unknown as { nativeEvent?: PointerEvent }) - .nativeEvent; - const pointerEvent = nativeEvent ?? (event as unknown as PointerEvent); + const handleOpenMenu = (event: React.MouseEvent) => { + setMenuAnchor(event.currentTarget); + }; + + const handleCloseMenu = () => { + setMenuAnchor(undefined); + }; + + const handleSelect = ( + selection: "standard" | "custom", + event: React.MouseEvent, + ) => { const keepCustomLayoutDataWhenSwitchingToStandard = - selection === "standard" && - "shiftKey" in pointerEvent && - "ctrlKey" in pointerEvent && - pointerEvent.shiftKey && - pointerEvent.ctrlKey; + selection === "standard" && event.shiftKey && event.ctrlKey; + handleCloseMenu(); props.setCustom(selection, keepCustomLayoutDataWhenSwitchingToStandard); }; @@ -44,52 +46,65 @@ export const CustomPageLayoutMenu: React.FunctionComponent<{
- {selectedLayoutLabel} - +
); diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx index 2914e8e4515f..f35bf535d482 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx @@ -1,8 +1,6 @@ // This collectionSettings reference defines the function GetSettings(): ICollectionSettings // The actual function is injected by C#. /// -import * as ReactDOM from "react-dom"; -import { CustomPageLayoutMenu } from "./customPageLayoutMenu"; import { CanvasElementManager, kBackgroundImageClass, @@ -17,10 +15,11 @@ import { recomputeSourceBubblesForPage, wrapWithRequestPageContentDelay, } from "../../js/bloomEditing"; +import { updateAbovePageControls } from "../../js/AbovePageControls"; import BloomSourceBubbles from "../../sourceBubbles/BloomSourceBubbles"; import { getToolboxBundleExports } from "../../js/workspaceFrames"; -import { ILanguageNameValues } from "../../bookSettings/FieldVisibilityGroup"; -import { isLegacyThemeCssLoaded } from "../../bookSettings/appearanceThemeUtils"; +import { ILanguageNameValues } from "../../bookAndPageSettings/FieldVisibilityGroup"; +import { isLegacyThemeCssLoaded } from "../../bookAndPageSettings/appearanceThemeUtils"; /* Summary of how custom covers work - a page (currently just cover) which can be customized has a new attribute, @@ -472,27 +471,7 @@ export function setupPageLayoutMenu(): void { return; } - // Create the container if needed (which it usually will be, because the cover - // is not a customPage and doesn't get one automatically). This duplicates - // (but without jquery) some code in origami.ts - let container: HTMLElement | undefined = document.getElementsByClassName( - "above-page-control-container", - )[0] as HTMLElement; - if (!container) { - container = document.createElement("div") as HTMLElement; - container.classList.add("above-page-control-container"); - container.classList.add("bloom-ui"); - container.style.maxWidth = page.clientWidth + "px"; - // see commment in origami.ts about why we put it first. - // the code there puts it at the start of #page-scaling-container, but that - // is always the parent of .bloom-page, so this is equivalent. - page.parentElement?.insertBefore( - container, - page.parentElement.firstChild, - ); - } - - renderPageLayoutMenu(page as HTMLElement, container as HTMLElement); + renderPageLayoutMenu(page as HTMLElement); if (page.classList.contains("bloom-customLayout")) { ensureDerivedFieldsFitOnCustomPage(page as HTMLElement); @@ -510,54 +489,54 @@ function toggleCustomPageLayout( }); } -function renderPageLayoutMenu(page: HTMLElement, container: HTMLElement): void { - // Render a CustomPageLayoutMenu React component into this container +function renderPageLayoutMenu(page: HTMLElement): void { const isCustomPage = page.classList.contains("bloom-customLayout"); const usingLegacyTheme = isLegacyThemeCssLoaded(); - ReactDOM.render( - { + if (usingLegacyTheme && selection !== "standard") { + return; + } + const response = await toggleCustomPageLayout( + page.getAttribute("id")!, keepCustomLayoutDataWhenSwitchingToStandard, - ) => { - if (usingLegacyTheme && selection !== "standard") { - return; - } - const response = await toggleCustomPageLayout( + ); + if ( + selection === "custom" && + response && + // C# returns the string "false" if we don't have any saved state for custom mode, + // but currently something in axios converts that to a boolean false. + // I'm not sure that might not change one day, so we check for both. + (response.data === "false" || response.data === false) + ) { + // making a custom cover for the first time + await wrapWithRequestPageContentDelay( + () => convertXmatterPageToCustom(page), + "customPageLayout-convertFirstTime", + ); + // Set data-tool-id on the browser DOM so it persists when jumpToPage saves. + // (The C# toggleCustomPageLayout set it on the C# DOM, but returned early + // without SaveThen, so that change would be overwritten by the browser save.) + page.setAttribute("data-tool-id", "canvas"); + // Persist the newly created custom layout state so a later toggle back + // to standard has matching server-side state to work from. + await postString( + "editView/jumpToPage", page.getAttribute("id")!, - keepCustomLayoutDataWhenSwitchingToStandard, ); - if ( - selection === "custom" && - response && - // C# returns the string "false" if we don't have any saved state for custom mode, - // but currently something in axios converts that to a boolean false. - // I'm not sure that might not change one day, so we check for both. - (response.data === "false" || response.data === false) - ) { - // making a custom cover for the first time - await wrapWithRequestPageContentDelay( - () => convertXmatterPageToCustom(page), - "customPageLayout-convertFirstTime", - ); - // Set data-tool-id on the browser DOM so it persists when jumpToPage saves. - // (The C# toggleCustomPageLayout set it on the C# DOM, but returned early - // without SaveThen, so that change would be overwritten by the browser save.) - page.setAttribute("data-tool-id", "canvas"); - // Persist the newly created custom layout state so a later toggle back - // to standard has matching server-side state to work from. - await postString( - "editView/jumpToPage", - page.getAttribute("id")!, - ); - renderPageLayoutMenu(page, container); - } else if (selection === "custom" && response) { - showCanvasTool(); // otherwise called from convertXmatterPageToCustom()/finishReactivatingPage() - } - }} - />, - container, - ); + renderPageLayoutMenu(page); + } else if (selection === "custom" && response) { + showCanvasTool(); // otherwise called from convertXmatterPageToCustom()/finishReactivatingPage() + renderPageLayoutMenu(page); + } else if (response) { + renderPageLayoutMenu(page); + } + }, + }); } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx b/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx index edd4fc2e4ccd..f359d3f1d301 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/games/DragActivityTabControl.tsx @@ -6,7 +6,6 @@ import { kDarkestBackground, toolboxTheme, } from "../../../bloomMaterialUITheme"; -import * as ReactDOM from "react-dom"; import { getToolboxBundleExports } from "../../js/workspaceFrames"; import { useL10n } from "../../../react_components/l10nHooks"; import { default as PencilIcon } from "@mui/icons-material/Edit"; @@ -49,16 +48,15 @@ export const DragActivityTabControl: React.FunctionComponent<{ return (
{promptButtonContent && ( @@ -90,6 +88,7 @@ export const DragActivityTabControl: React.FunctionComponent<{ css={css` margin-right: 20px; `} + className="above-page-control-typography" > {setupMode}
@@ -106,8 +105,12 @@ export const DragActivityTabControl: React.FunctionComponent<{ const buttonCss = css` color: white; width: auto; // override MUI's 100% + height: 24px; min-width: 32px; // override MUI's 64px + font-family: Roboto, NotoSans, sans-serif; + font-size: 9pt; font-weight: 400; + line-height: 16px; padding: 0px 6px; & .MuiButton-startIcon { top: -1px; @@ -123,23 +126,6 @@ const buttonCss = css` // also occurs in less files export const kIdForDragActivityTabControl = "drag-activity-tab-control"; -// This is the function that the editable page iframe exports so that the toolbox can call it -// to render the Start/Correct/Wrong/Play control. -// This deliberately does NOT use the cross-iframe getPage() function, because it MUST be -// called in a way that has it executing in the right context, where document refers to the -// page iframe document. The toolbox must call it through getEditablePageBundleExports(). -// This is because ReactDOM.render seems to have trouble if we pass it an element that -// belongs to a different document. -export function renderDragActivityTabControl(currentTab: number) { - const root = document.getElementById(kIdForDragActivityTabControl); - if (!root) { - // not created yet, try later - setTimeout(() => renderDragActivityTabControl(currentTab), 200); - return; - } - ReactDOM.render(, root); -} - export const Tabs: React.FunctionComponent<{ value: number; onChange: (newValue: number) => void; @@ -154,6 +140,8 @@ export const Tabs: React.FunctionComponent<{ css={css` display: flex; background-color: ${kDarkestBackground}; + align-items: center; + height: 24px; `} className={props.className} > diff --git a/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx b/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx index 00ab9f4055fe..170d4d524671 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx @@ -2007,25 +2007,6 @@ export function setupDragActivityTabControl() { if (!isPageBloomGame(page)) { return; } - const tabControl = page.ownerDocument.createElement("div"); - tabControl.setAttribute("id", kIdForDragActivityTabControl); - const abovePageControlContainer = page.ownerDocument.getElementsByClassName( - "above-page-control-container", - )[0]; - if (!abovePageControlContainer) { - // if it's not already created, keep trying until it is. - setTimeout(setupDragActivityTabControl, 200); - return; - } - // We want the Game controls exactly when we don't - // want origami, so we use the control container we usually use for origami, - // a nice wrapper inside the page (so we can - // get the correct page alignment) and have already arranged to delete before saving the page. - abovePageControlContainer.appendChild(tabControl); - // Seems strange that we need to do this to call a function in the same file, - // but currently this code is also pulled into the page bundle, and called from - // its initialization code, and it's vital to be consistent about the bundle - // from which event handler functions are taken, so they can later be removed. getToolboxBundleExports()?.setActiveDragActivityTab( getActiveDragActivityTab(), ); diff --git a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts index ea155a9afa4e..0332219fcf04 100644 --- a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts +++ b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts @@ -22,6 +22,7 @@ export interface IWorkspaceExports { task: (toolboxFrameExports: IToolboxFrameExports) => unknown, ); getModalDialogContainer(): HTMLElement | null; + ShowEditViewDialog(dialog: FunctionComponentElement): void; showConfirmDialog(props: IConfirmDialogProps): void; showColorPickerDialog(props: IColorPickerDialogProps): void; hideColorPickerDialog(): void; @@ -43,6 +44,7 @@ export interface IWorkspaceExports { emailRequiredForTeamCollection?: boolean, ): void; showAboutDialogFromWorkspaceRoot(): void; + showBookSettingsDialog(initiallySelectedPageKey?: string): void; } export function SayHello() { @@ -62,7 +64,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 }; @@ -274,11 +276,6 @@ export function showCopyrightAndLicenseDialog(imageUrl?: string) { export function showEditViewTopicChooserDialog() { showTopicChooserDialog(); } -export function showEditViewBookSettingsDialog( - initiallySelectedGroupIndex?: number, -) { - showBookSettingsDialog(initiallySelectedGroupIndex); -} export function showAboutDialogFromWorkspaceRoot() { showAboutDialog(); @@ -372,7 +369,6 @@ interface WorkspaceBundleApi { hideColorPickerDialog: typeof hideColorPickerDialog; showCopyrightAndLicenseDialog: typeof showCopyrightAndLicenseDialog; showEditViewTopicChooserDialog: typeof showEditViewTopicChooserDialog; - showEditViewBookSettingsDialog: typeof showEditViewBookSettingsDialog; showAboutDialogFromWorkspaceRoot: typeof showAboutDialogFromWorkspaceRoot; showRequiresSubscriptionDialog: typeof showRequiresSubscriptionDialog; showRegistrationDialogFromWorkspaceRoot: typeof showRegistrationDialogFromWorkspaceRoot; @@ -413,7 +409,6 @@ window.workspaceBundle = { hideColorPickerDialog, showCopyrightAndLicenseDialog, showEditViewTopicChooserDialog, - showEditViewBookSettingsDialog, showAboutDialogFromWorkspaceRoot, showRequiresSubscriptionDialog, showRegistrationDialogFromWorkspaceRoot, diff --git a/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx b/src/BloomBrowserUI/collection/CollectionSettingsDialog.tsx index 9a313beca1c8..f8954c460377 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, @@ -17,6 +18,7 @@ import { DialogCancelButton, DialogOkButton, } from "../react_components/BloomDialog/commonDialogComponents"; +import { useL10n } from "../react_components/l10nHooks"; import { get, postJson } from "../utils/bloomApi"; import { kBloomBlue } from "../bloomMaterialUITheme"; @@ -27,10 +29,6 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { propsForBloomDialog, } = useEventLaunchedBloomDialog("CollectionSettingsDialog"); - const [settings, setSettings] = React.useState( - undefined, - ); - const [settingsString, setSettingsString] = React.useState("{}"); // Fetch collection settings when the dialog opens so we sync with host state. React.useEffect(() => { @@ -40,33 +38,24 @@ export const CollectionSettingsDialog: React.FunctionComponent = () => { }); }, [propsForBloomDialog.open]); - const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< - string | object | 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(() => { + const settings = React.useMemo((): ConfigrValues | undefined => { if (settingsString === "{}") { - return; // leave settings as undefined + return undefined; } if (typeof settingsString === "string") { - setSettings(JSON.parse(settingsString)); - } else { - setSettings(settingsString); + return JSON.parse(settingsString) as ConfigrValues; } + return settingsString as unknown as ConfigrValues; }, [settingsString]); + const [settingsToReturnLater, setSettingsToReturnLater] = React.useState< + ConfigrValues | undefined + >(undefined); + const dialogTitle = useL10n( + "Collection Settings", + "CollectionSettingsDialog.Title", + ); + return ( { draggable={false} maxWidth={false} > - +
{ > {settings && ( { { - 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/react_components/BookInfoIndicator.tsx b/src/BloomBrowserUI/react_components/BookInfoIndicator.tsx index 5005cd85a330..3a539190ffd3 100644 --- a/src/BloomBrowserUI/react_components/BookInfoIndicator.tsx +++ b/src/BloomBrowserUI/react_components/BookInfoIndicator.tsx @@ -12,8 +12,8 @@ import { MessageUsingMigratedThemeInsteadOfIncompatibleCss, MessageUsingLegacyThemeWithIncompatibleCss, MessageIgnoringIncompatibleCssCanDelete, -} from "../bookEdit/bookSettings/BookSettingsDialog"; -import { isLegacyThemeName } from "../bookEdit/bookSettings/appearanceThemeUtils"; +} from "../bookEdit/bookAndPageSettings/BookSettingsConfigrPages"; +import { isLegacyThemeName } from "../bookEdit/bookAndPageSettings/appearanceThemeUtils"; export const BookInfoIndicator: React.FunctionComponent<{ bookId: string; diff --git a/src/BloomBrowserUI/react_components/BookSettingsButton.spec.tsx b/src/BloomBrowserUI/react_components/BookSettingsButton.spec.tsx new file mode 100644 index 000000000000..9df4b78b79b6 --- /dev/null +++ b/src/BloomBrowserUI/react_components/BookSettingsButton.spec.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../bookEdit/bookAndPageSettings/PageSettingsConfigrPages", () => ({ + getCurrentPageElement: vi.fn(), +})); + +import { getCurrentPageElement } from "../bookEdit/bookAndPageSettings/PageSettingsConfigrPages"; +import { getInitialBookSettingsPageKey } from "./BookSettingsButton"; + +const getCurrentPageElementMock = vi.mocked(getCurrentPageElement); + +describe("BookSettingsButton", () => { + it("defaults to content pages if the current page is not available yet", () => { + getCurrentPageElementMock.mockImplementation(() => { + throw new Error("page iframe not ready"); + }); + + expect(getInitialBookSettingsPageKey()).toBe("contentPages"); + }); + + it("uses the cover page key when the current page is a cover", () => { + const page = document.createElement("div"); + page.classList.add("cover"); + getCurrentPageElementMock.mockReturnValue(page); + + expect(getInitialBookSettingsPageKey()).toBe("cover"); + }); +}); diff --git a/src/BloomBrowserUI/react_components/BookSettingsButton.tsx b/src/BloomBrowserUI/react_components/BookSettingsButton.tsx index ee39cea9c6c8..1fc36550feee 100644 --- a/src/BloomBrowserUI/react_components/BookSettingsButton.tsx +++ b/src/BloomBrowserUI/react_components/BookSettingsButton.tsx @@ -1,6 +1,9 @@ +import { css } from "@emotion/react"; import * as React from "react"; import { TopBarButton } from "./TopBarButton"; -import { getBloomApiPrefix, post } from "../utils/bloomApi"; +import { getBloomApiPrefix } from "../utils/bloomApi"; +import { showBookSettingsDialog } from "../bookEdit/bookAndPageSettings/BookAndPageSettingsDialog"; +import { getCurrentPageElement } from "../bookEdit/bookAndPageSettings/PageSettingsConfigrPages"; import { kBloomPurple, kDisabledTextOnPurple, @@ -9,20 +12,41 @@ import { const bookSettingsIconPath = `${getBloomApiPrefix(false)}images/book-settings.png`; +export const getInitialBookSettingsPageKey = (): string => { + try { + return getCurrentPageElement().classList.contains("cover") + ? "cover" + : "contentPages"; + } catch { + return "contentPages"; + } +}; + export const BookSettingsButton: React.FunctionComponent = (props) => { const handleClick = React.useCallback(() => { - post("editView/showBookSettingsDialog"); + const pageKey = getInitialBookSettingsPageKey(); + showBookSettingsDialog(pageKey); }, []); return ( ); }; diff --git a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx index 94dade484804..b30381efc991 100644 --- a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx +++ b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx @@ -75,6 +75,17 @@ export const CollectionTopBarControls: React.FunctionComponent = () => { onClick={handleLegacySettingsClick} backgroundColor={mainButtonBackground} textColor={mainButtonTextColor} + cssOverrides={css` + width: 88px; + white-space: normal; + line-height: 1.15; + + span { + display: inline-block; + max-width: 64px; + text-align: center; + } + `} /> { const connection = await connectToBloomExe(); try { - const topBarFrame = await getBloomTopBarFrame(connection.page); - - await topBarFrame.getByRole("tab", { name: "Collections" }).click(); + await clickWorkspaceTab(connection.page, "Collections"); await waitForActiveWorkspaceTab("collection"); await expect( - topBarFrame.getByText("Settings", { exact: true }), + connection.page.getByText("Settings", { exact: true }), ).toBeVisible(); await expect( - topBarFrame.getByText("Other Collection", { exact: true }), + connection.page.getByText("Other Collection", { exact: true }), ).toBeVisible(); } finally { await connection.browser.close(); diff --git a/src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts b/src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts index a0b013072a93..dfd91b2f8dd5 100644 --- a/src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts +++ b/src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts @@ -1,30 +1,28 @@ import { test, expect } from "../../component-tester/playwrightTest"; import { + clickWorkspaceTab, connectToBloomExe, - getBloomTopBarFrame, waitForActiveWorkspaceTab, } from "../../component-tester/bloomExeCdp"; test.describe("Bloom exe CDP top bar", () => { - test("switches embedded workspace tabs through the real top bar iframe", async () => { + test("switches embedded workspace tabs through the real top bar", async () => { const connection = await connectToBloomExe(); try { - const topBarFrame = await getBloomTopBarFrame(connection.page); - - await topBarFrame.getByRole("tab", { name: "Collections" }).click(); + await clickWorkspaceTab(connection.page, "Collections"); await waitForActiveWorkspaceTab("collection"); await expect(connection.page.locator("body")).toHaveClass( /collection-mode/, ); - await topBarFrame.getByRole("tab", { name: "Publish" }).click(); + await clickWorkspaceTab(connection.page, "Publish"); await waitForActiveWorkspaceTab("publish"); await expect(connection.page.locator("body")).toHaveClass( /publish-mode/, ); - await topBarFrame.getByRole("tab", { name: "Edit" }).click(); + await clickWorkspaceTab(connection.page, "Edit"); await waitForActiveWorkspaceTab("edit"); await expect(connection.page.locator("body")).toHaveClass( /edit-mode/, @@ -38,7 +36,6 @@ test.describe("Bloom exe CDP top bar", () => { const connection = await connectToBloomExe(); try { - const topBarFrame = await getBloomTopBarFrame(connection.page); const consoleMessages: string[] = []; const requestUrls: string[] = []; @@ -59,7 +56,7 @@ test.describe("Bloom exe CDP top bar", () => { ) .toBe(true); - await topBarFrame.getByRole("tab", { name: "Publish" }).click(); + await clickWorkspaceTab(connection.page, "Publish"); await waitForActiveWorkspaceTab("publish"); await expect @@ -70,7 +67,7 @@ test.describe("Bloom exe CDP top bar", () => { ) .toBe(true); - await topBarFrame.getByRole("tab", { name: "Edit" }).click(); + await clickWorkspaceTab(connection.page, "Edit"); await waitForActiveWorkspaceTab("edit"); } finally { await connection.browser.close(); diff --git a/src/BloomBrowserUI/react_components/bloomButton.tsx b/src/BloomBrowserUI/react_components/bloomButton.tsx index f9660b3b5097..2dafe81f211a 100644 --- a/src/BloomBrowserUI/react_components/bloomButton.tsx +++ b/src/BloomBrowserUI/react_components/bloomButton.tsx @@ -75,10 +75,13 @@ export default class BloomButton extends LocalizableElement< l10nComment, clickApiEndpoint, mightNavigate, + transparent, enabledImageFile, disabledImageFile, hasText, iconBeforeText, + l10nTipEnglishEnabled, + l10nTipEnglishDisabled, temporarilyDisableI18nWarning, alreadyLocalized, ...propsToPass diff --git a/src/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/bloomSketchPicker.tsx b/src/BloomBrowserUI/react_components/color-picking/bloomSketchPicker.tsx index 12e8b4d7c825..4630728c6d60 100644 --- a/src/BloomBrowserUI/react_components/color-picking/bloomSketchPicker.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/bloomSketchPicker.tsx @@ -9,6 +9,7 @@ interface IBloomSketchPickerProps { noAlphaSlider?: boolean; onChange: (color: ColorResult) => void; + onChangeComplete?: (color: ColorResult) => void; // Needed for tooltip on Alpha slider currentOpacity: number; diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx index 11c657ba0eea..75af7708d4a2 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPicker.tsx @@ -1,13 +1,16 @@ import { css } from "@emotion/react"; import * as React from "react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ColorResult, RGBColor } from "react-color"; import BloomSketchPicker from "./bloomSketchPicker"; import ColorSwatch, { IColorInfo } from "./colorSwatch"; import tinycolor from "tinycolor2"; import { HexColorInput } from "./hexColorInput"; import { useL10n } from "../l10nHooks"; -import { Typography } from "@mui/material"; +import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; +import ColorizeIcon from "@mui/icons-material/Colorize"; +import { getColorInfoFromSpecialNameOrColorString } from "./bloomPalette"; // We are combining parts of the 'react-color' component set with our own list of swatches. // The reason for using our own swatches is so we can support swatches with gradients and alpha. @@ -15,33 +18,122 @@ interface IColorPickerProps { transparency?: boolean; noGradientSwatches?: boolean; onChange: (color: IColorInfo) => void; + onChangeComplete?: (color: IColorInfo) => void; currentColor: IColorInfo; swatchColors: IColorInfo[]; includeDefault?: boolean; onDefaultClick?: () => void; + onEyedropperActiveChange?: (active: boolean) => void; + eyedropperBackdropSelector?: string; defaultButtonLabel?: 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(); + } +}; + export const ColorPicker: React.FunctionComponent = ( props, ) => { - const [colorChoice, setColorChoice] = useState(props.currentColor); + const [eyedropperActive, setEyedropperActive] = useState(false); + const mountedRef = useRef(true); + const backdropSelector = + props.eyedropperBackdropSelector ?? defaultEyedropperBackdropSelector; + const hasNativeEyedropper = !!getEyeDropperConstructor(); + + // Track mount state so we don't update state after unmount, and to ensure any temporary + // backdrop overrides are removed if the component unmounts while the eyedropper is active. + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + setEyedropperBackdropTransparent(backdropSelector, false); + }; + }, [backdropSelector]); const defaultStyleLabel = useL10n( "Default for style", "EditTab.DirectFormatting.labelForDefaultColor", ); + const sampleColorTitle = useL10n("Sample Color", "ColorPicker.SampleColor"); const defaultButtonLabel = props.defaultButtonLabel ?? defaultStyleLabel; - const changeColor = (swatchColor: IColorInfo) => { - setColorChoice(swatchColor); - props.onChange(swatchColor); + const cloneColor = (color: IColorInfo): IColorInfo => { + return { + ...color, + colors: [...color.colors], + }; + }; + + const changeColor = ( + swatchColor: IColorInfo, + options?: { complete?: boolean }, + ) => { + const clonedColor = cloneColor(swatchColor); + props.onChange(clonedColor); + if (options?.complete) { + props.onChangeComplete?.(clonedColor); + } }; // Handler for when the user clicks on a swatch at the bottom of the picker. const handleSwatchClick = (swatchColor: IColorInfo) => () => { - changeColor(swatchColor); + changeColor(swatchColor, { complete: true }); }; // Handler for when the user clicks/drags in the BloomSketchPicker (Saturation, Hue and Alpha). @@ -50,13 +142,26 @@ export const ColorPicker: React.FunctionComponent = ( changeColor(newColor); }; + const handlePickerChangeComplete = (color: ColorResult) => { + const newColor = getColorInfoFromColorResult(color, ""); + props.onChangeComplete?.(cloneColor(newColor)); + }; + // Handler for when the user changes the hex code value (including pasting). const handleHexCodeChange = (hexColor: string) => { + let colorOnly = hexColor; + let newOpacity = props.currentColor.opacity; + + if (props.transparency && /^#[0-9A-Fa-f]{8}$/.test(hexColor)) { + colorOnly = hexColor.substring(0, 7); + newOpacity = parseInt(hexColor.substring(7, 9), 16) / 255; + } + const newColor = { - colors: [hexColor], - opacity: colorChoice.opacity, // Don't change opacity + colors: [colorOnly], + opacity: newOpacity, }; - changeColor(newColor); + changeColor(newColor, { complete: true }); }; const getColorInfoFromColorResult = ( @@ -83,11 +188,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); + + try { + const result = await new constructor().open(); + if (result?.sRGBHex) { + changeColor( + getColorInfoFromSpecialNameOrColorString(result.sRGBHex), + { complete: true }, + ); + } + } catch { + // The user can cancel (e.g. Escape), which rejects the promise. + } finally { + setEyedropperBackdropTransparent(backdropSelector, false); + if (mountedRef.current) { + setEyedropperActive(false); + props.onEyedropperActiveChange?.(false); + } + } + }; + const getColorSwatches = () => ( {props.swatchColors @@ -123,30 +261,53 @@ export const ColorPicker: React.FunctionComponent = ( overflow-x: hidden; `} > + {/* Keep the picker mounted during drags; remounting here breaks slider pointer capture. */}
+ {hasNativeEyedropper && ( + + + + )} diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.spec.ts b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.spec.ts new file mode 100644 index 000000000000..e2e4c9218dd6 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import { hideColorPickerDialog } from "./colorPickerDialog"; + +describe("colorPickerDialog", () => { + it("does not throw if hide is called before show", () => { + expect(() => hideColorPickerDialog()).not.toThrow(); + }); +}); diff --git a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx index 02504e36698d..0af054e59b36 100644 --- a/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/colorPickerDialog.tsx @@ -1,7 +1,7 @@ -import { css } from "@emotion/react"; +import { css, Global } from "@emotion/react"; import * as React from "react"; import * as ReactDOM from "react-dom"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getWorkspaceBundleExports } from "../../bookEdit/js/workspaceFrames"; import { ThemeProvider, StyledEngineProvider } from "@mui/material/styles"; import { lightTheme } from "../../bloomMaterialUITheme"; @@ -27,6 +27,46 @@ import { DialogOkButton, } from "../BloomDialog/commonDialogComponents"; +// These helpers don't depend on component state/props; keeping them outside avoids hook-deps issues. +const colorFilter = ( + color: IColorInfo, + transparency?: boolean, + noGradientSwatches?: boolean, +): boolean => { + if (!transparency && color.opacity !== 1) { + return true; + } + if (noGradientSwatches && color.colors.length > 1) { + return true; + } + return false; +}; + +const colorCompareFunc = + (colorA: IColorInfo) => + (colorB: IColorInfo): boolean => { + if (colorB.colors.length !== colorA.colors.length) { + return false; // One is a gradient and the other is not. + } + if (colorA.colors.length > 1) { + // In the case of both being gradients, check the second color first. + const gradientAColor2 = tinycolor(colorA.colors[1]); + const gradientBColor2 = tinycolor(colorB.colors[1]); + if (gradientAColor2.toHex() !== gradientBColor2.toHex()) { + return false; + } + } + const gradientAColor1 = tinycolor(colorA.colors[0]); + const gradientBColor1 = tinycolor(colorB.colors[0]); + return ( + gradientAColor1.toHex() === gradientBColor1.toHex() && + colorA.opacity === colorB.opacity + ); + }; + +const isColorInThisArray = (color: IColorInfo, arrayOfColors: IColorInfo[]) => + !!arrayOfColors.find(colorCompareFunc(color)); + export interface IColorPickerDialogProps { open?: boolean; close?: (result: DialogResult) => void; @@ -37,6 +77,7 @@ export interface IColorPickerDialogProps { palette: BloomPalette; isForCanvasElement?: boolean; onChange: (color: IColorInfo) => void; + onChangeComplete?: (color: IColorInfo) => void; onDefaultClick?: () => void; onInputFocus: (input: HTMLElement) => void; includeDefault?: boolean; @@ -44,7 +85,7 @@ export interface IColorPickerDialogProps { //defaultColor?: IColorInfo; eventually we'll need this } -let externalSetOpen: React.Dispatch>; +let externalSetOpen: React.Dispatch> = () => {}; const ColorPickerDialog: React.FC = (props) => { const MAX_SWATCHES = 21; @@ -52,6 +93,13 @@ const ColorPickerDialog: React.FC = (props) => { props.open === undefined ? true : props.open, ); const [currentColor, setCurrentColor] = useState(props.initialColor); + const [eyedropperActive, setEyedropperActive] = useState(false); + + // Use a content-based key so we don't treat a new object reference with the + // same values as a meaningful change (important for callers that compute + // initialColor inline). + const initialColorKey = + props.initialColor.colors.join("|") + "|" + props.initialColor.opacity; const [swatchColorArray, setSwatchColorArray] = useState( getDefaultColorsFromPalette(props.palette), @@ -60,19 +108,105 @@ const ColorPickerDialog: React.FC = (props) => { externalSetOpen = setOpen; const dlgRef = useRef(null); - function addCustomColors(endpoint: string): void { - get(endpoint, (result) => { - const jsonArray = result.data; - if (!jsonArray.map) { - return; // this means the conversion string -> JSON didn't work. Bad JSON? - } - const customColors = convertJsonColorArrayToColorInfos(jsonArray); - addNewColorsToArrayIfNecessary(customColors); - }); - } + // We come to here on opening to add colors already in the book and we come here on closing to see + // if our new current color needs to be added to our array. + // Enhance: What if the number of distinct colors already used in the book that we get back, plus the number + // of other default colors is more than will fit in our array (current 21)? When we get colors from the book, + // we should maybe start with the current page, to give them a better chance of being included in the picker. + const addNewColorsToArrayIfNecessary = useCallback( + (newColors: IColorInfo[]) => { + // Every time we reference the current swatchColorArray inside + // this setter, we must use previousSwatchColorArray. + // Otherwise, we add to a stale array. + setSwatchColorArray((previousSwatchColorArray) => { + const newColorsAdded: IColorInfo[] = []; + const lengthBefore = previousSwatchColorArray.length; + let numberToDelete = 0; + // CustomColorPicker is going to filter these colors out anyway. + let numberToSkip = previousSwatchColorArray.filter((color) => + colorFilter( + 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 ( + colorFilter( + newColor, + props.transparency, + props.noGradientSwatches, + ) + ) { + numberToSkip++; + } + if ( + lengthBefore + newColorsAdded.length + 1 > + MAX_SWATCHES + numberToSkip + ) { + numberToDelete++; + } + newColorsAdded.unshift(newColor); // add newColor to the beginning of the array. + }); + const newSwatchColorArray = previousSwatchColorArray.slice(); // Get a new array copy of the old (a different reference) + if (numberToDelete > 0) { + // Remove 'numberToDelete' swatches from oldest custom swatches + const defaultNumber = getDefaultColorsFromPalette( + props.palette, + ).length; + const indexToRemove = + previousSwatchColorArray.length - + defaultNumber - + numberToDelete; + if (indexToRemove >= 0) { + newSwatchColorArray.splice( + indexToRemove, + numberToDelete, + ); + } else { + const excess = indexToRemove * -1; // index went negative; excess is absolute value + newSwatchColorArray.splice(0, numberToDelete - excess); + newColorsAdded.splice( + newColorsAdded.length - excess, + excess, + ); + } + } + const result = newColorsAdded.concat(newSwatchColorArray); + //console.log(result); + return result; + }); + }, + [props.noGradientSwatches, props.palette, props.transparency], + ); + // When the dialog is (re)opened, initialize swatches and currentColor. + // We depend on initialColorKey rather than props.initialColor to avoid resetting the UI + // if a caller passes a new object reference with the same color values on each render. useEffect(() => { if (props.open || open) { + const addCustomColors = (endpoint: string): void => { + get(endpoint, (result) => { + const jsonArray = result.data; + if (!jsonArray.map) { + return; // this means the conversion string -> JSON didn't work. Bad JSON? + } + const customColors = + convertJsonColorArrayToColorInfos(jsonArray); + addNewColorsToArrayIfNecessary(customColors); + }); + }; + setSwatchColorArray(getDefaultColorsFromPalette(props.palette)); addCustomColors( `settings/getCustomPaletteColors?palette=${props.palette}`, @@ -86,18 +220,31 @@ 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, + ]); - const focusFunc = (ev: FocusEvent) => { - props.onInputFocus(ev.currentTarget as HTMLElement); - }; + const onInputFocus = props.onInputFocus; - React.useEffect(() => { + // Install focus listeners on inputs so the client can restore focus when canvas updates steal it; + // this effect is necessary because the inputs live in the rendered DOM, not in React props/state, + // and we want the listener to stay aligned with the latest onInputFocus callback. + useEffect(() => { const parent = dlgRef.current; if (!parent) { return; } + const focusFunc = (ev: FocusEvent) => { + onInputFocus(ev.currentTarget as HTMLElement); + }; + // When we make incremental color changes while editing one of these inputs, // the process of applying the changed color to the canvas element moves the focus // to the canvas element. This makes it painfully necessary to click back in the input @@ -129,7 +276,7 @@ const ColorPickerDialog: React.FC = (props) => { input.removeEventListener("focus", focusFunc), ); }; - }, [dlgRef.current]); + }, [onInputFocus]); const convertJsonColorArrayToColorInfos = ( jsonArray: IColorInfo[], @@ -158,6 +305,7 @@ const ColorPickerDialog: React.FC = (props) => { setOpen(false); if (result === DialogResult.Cancel) { props.onChange(props.initialColor); + props.onChangeComplete?.(props.initialColor); setCurrentColor(props.initialColor); } else { if (!isColorInCurrentSwatchColorArray(currentColor)) { @@ -174,119 +322,51 @@ const ColorPickerDialog: React.FC = (props) => { } }; - // We come to here on opening to add colors already in the book and we come here on closing to see - // if our new current color needs to be added to our array. - // Enhance: What if the number of distinct colors already used in the book that we get back, plus the number - // of other default colors is more than will fit in our array (current 21)? When we get colors from the book, - // we should maybe start with the current page, to give them a better chance of being included in the picker. - const addNewColorsToArrayIfNecessary = (newColors: IColorInfo[]) => { - // Every time we reference the current swatchColorArray inside - // this setter, we must use previousSwatchColorArray. - // Otherwise, we add to a stale array. - setSwatchColorArray((previousSwatchColorArray) => { - const newColorsAdded: IColorInfo[] = []; - const lengthBefore = previousSwatchColorArray.length; - let numberToDelete = 0; - // CustomColorPicker is going to filter these colors out anyway. - let numberToSkip = previousSwatchColorArray.filter((color) => - willSwatchColorBeFilteredOut(color), - ).length; - newColors.forEach((newColor) => { - if (isColorInThisArray(newColor, previousSwatchColorArray)) { - return; // This one is already in our array of swatch colors - } - if (isColorInThisArray(newColor, newColorsAdded)) { - return; // We don't need to add the same color more than once! - } - // At first I wanted to do this filtering outside the loop, but some of them might be pre-filtered - // by the above two conditions. - if (willSwatchColorBeFilteredOut(newColor)) { - numberToSkip++; - } - if ( - lengthBefore + newColorsAdded.length + 1 > - MAX_SWATCHES + numberToSkip - ) { - numberToDelete++; - } - newColorsAdded.unshift(newColor); // add newColor to the beginning of the array. - }); - const newSwatchColorArray = swatchColorArray.slice(); // Get a new array copy of the old (a different reference) - if (numberToDelete > 0) { - // Remove 'numberToDelete' swatches from oldest custom swatches - const defaultNumber = getDefaultColorsFromPalette( - props.palette, - ).length; - const indexToRemove = - swatchColorArray.length - defaultNumber - numberToDelete; - if (indexToRemove >= 0) { - newSwatchColorArray.splice(indexToRemove, numberToDelete); - } else { - const excess = indexToRemove * -1; // index went negative; excess is absolute value - newSwatchColorArray.splice(0, numberToDelete - excess); - newColorsAdded.splice( - newColorsAdded.length - excess, - excess, - ); - } - } - const result = newColorsAdded.concat(previousSwatchColorArray); - //console.log(result); - return result; - }); - }; - const isColorInCurrentSwatchColorArray = (color: IColorInfo): boolean => isColorInThisArray(color, swatchColorArray); - const willSwatchColorBeFilteredOut = (color: IColorInfo): boolean => { - if (!props.transparency && color.opacity !== 1) { - return true; - } - if (props.noGradientSwatches && color.colors.length > 1) { - return true; - } - return false; + const handleOnChange = (color: IColorInfo) => { + const clonedColor: IColorInfo = { + ...color, + colors: [...color.colors], + }; + setCurrentColor(clonedColor); + props.onChange(clonedColor); }; - // Use a compare function to see if the color in question matches on already in this list or not. - const isColorInThisArray = ( - color: IColorInfo, - arrayOfColors: IColorInfo[], - ): boolean => !!arrayOfColors.find(colorCompareFunc(color)); - - // Function for comparing a color with an array of colors to see if the color is already - // in the array. We pass this function to .find(). - const colorCompareFunc = - (colorA: IColorInfo) => - (colorB: IColorInfo): boolean => { - if (colorB.colors.length !== colorA.colors.length) { - return false; // One is a gradient and the other is not. - } - if (colorA.colors.length > 1) { - // In the case of both being gradients, check the second color first. - const gradientAColor2 = tinycolor(colorA.colors[1]); - const gradientBColor2 = tinycolor(colorB.colors[1]); - if (gradientAColor2.toHex() !== gradientBColor2.toHex()) { - return false; - } - } - const gradientAColor1 = tinycolor(colorA.colors[0]); - const gradientBColor1 = tinycolor(colorB.colors[0]); - return ( - gradientAColor1.toHex() === gradientBColor1.toHex() && - colorA.opacity === colorB.opacity - ); + const handleOnChangeComplete = (color: IColorInfo) => { + const clonedColor: IColorInfo = { + ...color, + colors: [...color.colors], }; - - const handleOnChange = (color: IColorInfo) => { - setCurrentColor(color); - props.onChange(color); + props.onChangeComplete?.(clonedColor); }; + const dialogOpen = props.open === undefined ? open : props.open; + + // The color picker often opens from inside another dialog. MUI renders that + // outer backdrop outside the nested dialog tree, so we suppress it at the body + // level while keeping this dialog's own invisible backdrop for outside-click handling. + useEffect(() => { + if (!dialogOpen) { + return; + } + document.body.classList.add("bloom-hide-color-picker-backdrop"); + return () => { + document.body.classList.remove("bloom-hide-color-picker-backdrop"); + }; + }, [dialogOpen]); + return ( + = (props) => { padding: 10px 14px 10px 10px; // maintain same spacing all around dialog content and between header/footer } `} - open={props.open === undefined ? open : props.open} + BackdropProps={{ + invisible: true, + }} + slotProps={{ + backdrop: { + invisible: true, + }, + }} + open={dialogOpen} ref={dlgRef} onClose={( _event, reason: "backdropClick" | "escapeKeyDown", ) => { - if (reason === "backdropClick") + if (eyedropperActive) { + return; + } + if (reason === "backdropClick") { onClose(DialogResult.OK); + return; + } if (reason === "escapeKeyDown") onClose(DialogResult.Cancel); }} @@ -325,12 +418,14 @@ const ColorPickerDialog: React.FC = (props) => { @@ -368,13 +463,7 @@ export const showColorPickerDialog = ( }; export const hideColorPickerDialog = () => { - // I'm not sure if this can be falsy, but whereas in the method above we're calling it - // immediately after we render the dialog, which sets it, this gets called long after - // when the tool is closed. Just in case it somehow gets cleared, now or in some future - // version of the code, I decided to leave in the check that CoPilot proposed. - if (externalSetOpen) { - externalSetOpen(false); - } + externalSetOpen(false); }; const doRender = ( @@ -414,12 +503,21 @@ export const showSimpleColorPickerDialog = ( props.initialColor, ), palette: props.palette, - onChange: (color: IColorInfo) => props.onChange(color.colors[0]), + onChange: (color: IColorInfo) => + props.onChange(getColorStringFromColorInfo(color)), onInputFocus: props.onInputFocus, }; showColorPickerDialog(fullProps, props.container); }; +const getColorStringFromColorInfo = (color: IColorInfo): string => { + const firstColor = color.colors[0]; + if (color.opacity === 1) { + return firstColor; + } + return getRgbaColorStringFromColorAndOpacity(firstColor, color.opacity); +}; + export interface IColorDisplayButtonProps { // This is slightly more than an initial color. The button will change color // independently of this to follow the state of the color picker dialog; @@ -430,19 +528,58 @@ export interface IColorDisplayButtonProps { transparency: boolean; width?: number; disabled?: boolean; + deferOnChangeUntilComplete?: boolean; onClose: (result: DialogResult, newColor: string) => void; + onChange?: (newColor: string) => void; + onColorPickerVisibilityChanged?: (open: boolean) => void; palette: BloomPalette; } export const ColorDisplayButton: React.FC = ( props, ) => { + const onColorPickerVisibilityChanged = props.onColorPickerVisibilityChanged; + const deferOnChangeUntilComplete = props.deferOnChangeUntilComplete; + const onChange = props.onChange; const [dialogOpen, setDialogOpen] = useState(false); + const [colorAtDialogOpen, setColorAtDialogOpen] = useState( + props.initialColor, + ); const [currentButtonColor, setCurrentButtonColor] = useState( props.initialColor, ); const widthString = props.width ? `width: ${props.width}px;` : ""; + const initialColorInfo = React.useMemo( + () => + getColorInfoFromSpecialNameOrColorString( + dialogOpen ? colorAtDialogOpen : props.initialColor, + ), + [props.initialColor, dialogOpen, colorAtDialogOpen], + ); + + const handleDialogChange = React.useCallback( + (color: IColorInfo) => { + const newColor = getColorStringFromColorInfo(color); + setCurrentButtonColor(newColor); + if (!deferOnChangeUntilComplete && onChange) { + onChange(newColor); + } + }, + [deferOnChangeUntilComplete, onChange], + ); + + const handleDialogChangeComplete = React.useCallback( + (color: IColorInfo) => { + const newColor = getColorStringFromColorInfo(color); + setCurrentButtonColor(newColor); + if (onChange) { + onChange(newColor); + } + }, + [onChange], + ); + useEffect(() => { if (currentButtonColor !== props.initialColor) { setCurrentButtonColor(props.initialColor); @@ -454,26 +591,78 @@ export const ColorDisplayButton: React.FC = ( // other than a new props value changes it. ) // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.initialColor]); + + // Clean up the visibility-changed callback when the component unmounts so the parent + // dialog is not left permanently hidden if the color picker was open at unmount time. + useEffect(() => { + return () => { + if (onColorPickerVisibilityChanged) { + onColorPickerVisibilityChanged(false); + } + }; + }, [onColorPickerVisibilityChanged]); + return ( -
+ <>
+
{ if (props.disabled) return; + if (onColorPickerVisibilityChanged) { + onColorPickerVisibilityChanged(true); + } + setColorAtDialogOpen(props.initialColor); setDialogOpen(true); }} /> @@ -482,24 +671,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={handleDialogChange} + onChangeComplete={ + deferOnChangeUntilComplete + ? handleDialogChangeComplete + : undefined } /> -
+ ); }; diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts new file mode 100644 index 000000000000..e7fa30666fe6 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButton.uitest.ts @@ -0,0 +1,124 @@ +import { test, expect } from "../../component-tester/playwrightTest"; +import { setTestComponent } from "../../component-tester/setTestComponent"; + +test.describe("ColorDisplayButton + ColorPickerDialog", () => { + test("single swatch click updates hex input in dialog", async ({ + page, + }) => { + await page.route( + "**/settings/getCustomPaletteColors?palette=*", + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: "[]", + }), + ); + + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + {}, + ); + + await page.getByTestId("color-display-button-swatch").click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + const hexInput = dialog.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111"); + + await dialog.locator(".swatch-row .color-swatch").first().click(); + await expect(hexInput).not.toHaveValue("#111111"); + }); + + test("transparent selection keeps transparency background visible", async ({ + page, + }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + { + initialColor: "transparent", + transparency: true, + }, + ); + + const transparencyBackground = page.getByTestId( + "color-display-button-transparency-background", + ); + await expect(transparencyBackground).toBeVisible({ timeout: 5000 }); + + const backgroundImage = await transparencyBackground.evaluate( + (element) => getComputedStyle(element).backgroundImage, + ); + expect(backgroundImage).not.toBe("none"); + + await expect(page.getByTestId("color-display-button-swatch")).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + }); + + test("deferred change waits until drag completes and cancel restores", async ({ + page, + }) => { + await page.route( + "**/settings/getCustomPaletteColors?palette=*", + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: "[]", + }), + ); + + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + { + initialColor: "#00AA00", + deferOnChangeUntilComplete: true, + }, + ); + + await page.getByTestId("color-display-button-swatch").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.getByTestId("change-count")).toHaveText("0"); + + const hue = page.locator(".hue-horizontal"); + const box = await hue.boundingBox(); + expect(box).not.toBeNull(); + + await page.mouse.move(box!.x + 5, box!.y + box!.height / 2); + await page.mouse.down(); + await page.mouse.move( + box!.x + box!.width * 0.65, + box!.y + box!.height / 2, + { + steps: 8, + }, + ); + + await expect(page.getByTestId("change-count")).toHaveText("0"); + + await page.mouse.up(); + + await expect(page.getByTestId("change-count")).toHaveText("1"); + await expect(page.getByTestId("last-changed-color")).not.toHaveText( + "#00AA00", + ); + + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByTestId("change-count")).toHaveText("2"); + await expect(page.getByTestId("last-changed-color")).toHaveText( + "#00aa00", + ); + await expect(page.getByTestId("close-result")).toHaveText("cancel"); + }); +}); diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx new file mode 100644 index 000000000000..ab8e9bd17f52 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorDisplayButtonTestHarness.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { useState } from "react"; +import { ColorDisplayButton, DialogResult } from "../colorPickerDialog"; +import { BloomPalette } from "../bloomPalette"; + +export const ColorDisplayButtonTestHarness: React.FunctionComponent<{ + initialColor?: string; + transparency?: boolean; + deferOnChangeUntilComplete?: boolean; +}> = (props) => { + const [changeCount, setChangeCount] = useState(0); + const [lastChangedColor, setLastChangedColor] = useState(""); + const [closeResult, setCloseResult] = useState(""); + + return ( +
+
{changeCount}
+
{lastChangedColor}
+
{closeResult}
+ { + setChangeCount((previousCount) => previousCount + 1); + setLastChangedColor(newColor); + }} + onClose={(result: DialogResult, _newColor: string) => { + setCloseResult( + result === DialogResult.OK ? "ok" : "cancel", + ); + }} + /> +
+ ); +}; diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts new file mode 100644 index 000000000000..5af52014ad76 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPicker.uitest.ts @@ -0,0 +1,105 @@ +import { test, expect } from "../../component-tester/playwrightTest"; +import { setTestComponent } from "../../component-tester/setTestComponent"; + +test.describe("ColorPicker", () => { + test("single swatch click updates hex input", async ({ page }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const hexInput = page.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111FF"); + + await page.locator(".swatch-row .color-swatch").first().click(); + await expect(hexInput).toHaveValue("#AA0000FF"); + }); + + test("eyedropper (native) updates hex input", async ({ page }) => { + await page.addInitScript(() => { + ( + window as unknown as Window & { + EyeDropper: { + new (): { open: () => Promise<{ sRGBHex: string }> }; + }; + } + ).EyeDropper = class { + public async open(): Promise<{ sRGBHex: string }> { + return { sRGBHex: "#00AA00" }; + } + }; + }); + + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const hexInput = page.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111FF"); + + await page.locator('button[title="Sample Color"]').click(); + await expect(hexInput).toHaveValue("#00AA00FF"); + }); + + test("external currentColor change updates hex input", async ({ page }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const hexInput = page.locator('input[type="text"]'); + await expect(hexInput).toHaveValue("#111111FF"); + + await page.getByTestId("simulate-external-color").click(); + await expect(hexInput).toHaveValue("#123456FF"); + }); + + test("hue slider supports continuous drag updates", async ({ page }) => { + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerTestHarness", + "ColorPickerTestHarness", + {}, + ); + + const swatches = page.locator(".swatch-row .color-swatch"); + await swatches.nth(1).click(); + + const hexInput = page.locator('input[type="text"]'); + const beforeDrag = await hexInput.inputValue(); + + const hue = page.locator(".hue-horizontal"); + const box = await hue.boundingBox(); + expect(box).not.toBeNull(); + + await page.mouse.move(box!.x + 5, box!.y + box!.height / 2); + await page.mouse.down(); + await page.mouse.move( + box!.x + box!.width * 0.35, + box!.y + box!.height / 2, + { + steps: 8, + }, + ); + const duringDrag = await hexInput.inputValue(); + await page.mouse.move( + box!.x + box!.width * 0.7, + box!.y + box!.height / 2, + { + steps: 8, + }, + ); + await page.mouse.up(); + const afterDrag = await hexInput.inputValue(); + + expect(beforeDrag).not.toEqual(duringDrag); + expect(duringDrag).not.toEqual(afterDrag); + }); +}); diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerManualHarness.tsx b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerManualHarness.tsx new file mode 100644 index 000000000000..1c1514f9a0b0 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerManualHarness.tsx @@ -0,0 +1,41 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { useState } from "react"; +import { ColorPicker } from "../colorPicker"; +import { IColorInfo } from "../colorSwatch"; + +export const ColorPickerManualHarness: React.FunctionComponent = () => { + const [currentColor, setCurrentColor] = useState({ + colors: ["#E48C84"], + opacity: 1, + }); + + const swatches: IColorInfo[] = [ + { colors: ["#E48C84"], opacity: 1 }, + { colors: ["#B58B4F"], opacity: 1 }, + { colors: ["#7E5A3C"], opacity: 1 }, + { colors: ["#F0E5D8"], opacity: 1 }, + { colors: ["#D9A6A0"], opacity: 1 }, + { colors: ["#8C6A5A"], opacity: 1 }, + { colors: ["#6D7A7B"], opacity: 1 }, + { colors: ["#F0D36E"], opacity: 1 }, + { colors: ["#85B2C2"], opacity: 1 }, + ]; + + return ( +
+ { + setCurrentColor(color); + }} + /> +
+ ); +}; diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerTestHarness.tsx b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerTestHarness.tsx new file mode 100644 index 000000000000..45092d444ec4 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/colorPickerTestHarness.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import { useState } from "react"; +import { ColorPicker } from "../colorPicker"; +import { IColorInfo } from "../colorSwatch"; + +export const ColorPickerTestHarness: React.FunctionComponent = () => { + const [currentColor, setCurrentColor] = useState({ + colors: ["#111111"], + opacity: 1, + }); + + const swatches: IColorInfo[] = [ + { colors: ["#AA0000"], opacity: 1 }, + { colors: ["#00AA00"], opacity: 1 }, + { colors: ["#0000AA"], opacity: 1 }, + ]; + + return ( +
+ + +
+ {currentColor.colors.join("|") + "|" + currentColor.opacity} +
+ + setCurrentColor(color)} + /> +
+ ); +}; diff --git a/src/BloomBrowserUI/react_components/color-picking/component-tests/show-component.uitest.ts b/src/BloomBrowserUI/react_components/color-picking/component-tests/show-component.uitest.ts new file mode 100644 index 000000000000..0d1957b5fded --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/component-tests/show-component.uitest.ts @@ -0,0 +1,58 @@ +/** + * Interactive manual testing mode using Playwright. + * This opens a visible browser with the component and keeps it open indefinitely + * so you can interact with it manually. + * + * Run with: ./show.sh + */ +import { test } from "../../component-tester/playwrightTest"; +import { setTestComponent } from "../../component-tester/setTestComponent"; + +const includeManualTests = process.env.PLAYWRIGHT_INCLUDE_MANUAL === "1"; +const manualDescribe = includeManualTests ? test.describe : test.describe.skip; + +manualDescribe("Manual Interactive Testing", () => { + test("default", async ({ page }) => { + test.setTimeout(0); + + await setTestComponent( + page, + "../color-picking/component-tests/colorPickerManualHarness", + "ColorPickerManualHarness", + {}, + ); + + await page.waitForEvent("close"); + }); + + test("dialog", async ({ page }) => { + test.setTimeout(0); + + await page.route( + "**/settings/getCustomPaletteColors?palette=*", + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: "[]", + }), + ); + + await setTestComponent( + page, + "../color-picking/component-tests/colorDisplayButtonTestHarness", + "ColorDisplayButtonTestHarness", + {}, + ); + + await page.waitForEvent("close"); + }); + + test("with-bloom-backend", async ({ page }) => { + test.setTimeout(0); + + await page.goto("/?component=ColorSwatch"); + + await page.waitForEvent("close"); + }); +}); diff --git a/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx b/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx index 73bbdf33699d..3e3a03422b9b 100644 --- a/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx +++ b/src/BloomBrowserUI/react_components/color-picking/hexColorInput.tsx @@ -7,54 +7,92 @@ import { IColorInfo } from "./colorSwatch"; interface IHexColorInputProps { initial: IColorInfo; onChangeComplete: (newValue: string) => void; + includeOpacityChannel?: boolean; } const hashChar = "#"; +const massageColorInput = ( + color: string, + includeOpacityChannel?: boolean, +): string => { + let result = color.toUpperCase(); + result = result.replace(/[^0-9A-F]/g, ""); // eliminate any non-hex characters + result = hashChar + result; // insert hash as the first character + const maxLength = includeOpacityChannel ? 9 : 7; + if (result.length > maxLength) { + result = result.slice(0, maxLength); + } + return result; +}; + +// In general, we want our Hex Color input to reflect the first value in the 'colors' array. +// For our predefined gradients, however, we want the hex input to be empty. +// And for named colors, we need to show the hex equivalent. +const getHexColorValueFromColorInfo = ( + colorInfo: IColorInfo, + includeOpacityChannel?: boolean, +): string => { + // First, our hex value will be empty, if we're dealing with a gradient. + // The massage method below will add a hash character... + if (colorInfo.colors.length > 1) return ""; + const firstColor = colorInfo.colors[0]; + const hexColor = tinycolor(firstColor).toHexString(); + + if (!includeOpacityChannel) { + return hexColor; + } + + const alphaHex = Math.round(colorInfo.opacity * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `${hexColor}${alphaHex}`; +}; + export const HexColorInput: React.FunctionComponent = ( props, ) => { - const [currentColor, setCurrentColor] = useState(""); + const getHexValue = React.useCallback( + (colorInfo: IColorInfo): string => + massageColorInput( + getHexColorValueFromColorInfo( + colorInfo, + props.includeOpacityChannel, + ), + props.includeOpacityChannel, + ), + [props.includeOpacityChannel], + ); - // In general, we want our Hex Color input to reflect the first value in the 'colors' array. - // For our predefined gradients, however, we want the hex input to be empty. - // And for named colors, we need to show the hex equivalent. - const getHexColorValueFromColorInfo = (): string => { - // First, our hex value will be empty, if we're dealing with a gradient. - // The massage method below will add a hash character... - if (props.initial.colors.length > 1) return ""; - const firstColor = props.initial.colors[0]; - if (firstColor[0] === hashChar) return firstColor; - // In some cases we might be dealing with a color word like "black" or "white" or "transparent". - return tinycolor(firstColor).toHexString(); - }; + const [currentColor, setCurrentColor] = useState(() => + getHexValue(props.initial), + ); - const massageColorInput = (color: string): string => { - let result = color.toUpperCase(); - result = result.replace(/[^0-9A-F]/g, ""); // eliminate any non-hex characters - result = hashChar + result; // insert hash as the first character - if (result.length > 7) { - result = result.slice(0, 7); - } - return result; - }; + const initialHexValue = getHexValue(props.initial); + // Keep the displayed hex string in sync when the parent changes the color programmatically + // (e.g. swatch click, eyedropper, or external currentColor updates). useEffect(() => { - setCurrentColor(massageColorInput(getHexColorValueFromColorInfo())); - }, [props.initial.colors]); + setCurrentColor(initialHexValue); + }, [initialHexValue]); const handleInputChange: React.ChangeEventHandler = ( e, ) => { - const result = massageColorInput(e.target.value); + const result = massageColorInput( + e.target.value, + props.includeOpacityChannel, + ); setCurrentColor(result); - if (result.length === 7) { + const completeLength = props.includeOpacityChannel ? 9 : 7; + if (result.length === completeLength) { props.onChangeComplete(result); } }; const borderThickness = 2; - const controlWidth = 60; // This width handles "#DDDDDD" as the maximum width input. + const controlWidth = props.includeOpacityChannel ? 80 : 60; const inputWidth = controlWidth - 2 * borderThickness; return ( diff --git a/src/BloomBrowserUI/react_components/color-picking/show.sh b/src/BloomBrowserUI/react_components/color-picking/show.sh new file mode 100644 index 000000000000..8ec1f479cb96 --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/show.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Manual testing for color-picking +# Uses Playwright with full mock support from test-helpers.ts +# Usage: ./show.sh [test-name] + +set -euo pipefail + +COMPONENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPONENT_NAME="$(basename "$COMPONENT_DIR")" + +cd "$COMPONENT_DIR/../component-tester" + +./show-component.sh "$COMPONENT_NAME" "$@" diff --git a/src/BloomBrowserUI/react_components/color-picking/test.sh b/src/BloomBrowserUI/react_components/color-picking/test.sh new file mode 100644 index 000000000000..fddac4e6631c --- /dev/null +++ b/src/BloomBrowserUI/react_components/color-picking/test.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Run automated UI tests for this component +set -e + +script_dir="$(cd "$(dirname "$0")" && pwd)" +cd "$script_dir/../component-tester" + +component_path="../color-picking/component-tests" + +if [ "${1:-}" = "--ui" ]; then + shift + yarn test:ui "$component_path" "$@" +else + yarn test "$component_path" "$@" +fi diff --git a/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts b/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts index c30090fb3a44..5c6ff338362e 100644 --- a/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts +++ b/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts @@ -1,4 +1,4 @@ -import { Browser, Frame, Page, chromium } from "./playwrightTest"; +import { Browser, Page, chromium } from "./playwrightTest"; type WorkspaceTabId = "collection" | "edit" | "publish"; const configuredCdpPort = process.env.BLOOM_CDP_PORT; @@ -66,19 +66,23 @@ export const connectToBloomExe = async (): Promise<{ return { browser, page }; }; -export const getBloomTopBarFrame = async (page: Page): Promise => { - const topBarHandle = await page.$("#topBar"); - if (!topBarHandle) { - throw new Error("Could not find the Bloom topBar iframe."); - } +export const clickWorkspaceTab = async ( + page: Page, + name: WorkspaceTabId extends infer _T + ? "Collections" | "Edit" | "Publish" + : never, +): Promise => { + await page.waitForSelector("#main-tabs button", { + timeout: 10000, + }); - const frame = await topBarHandle.contentFrame(); - if (!frame) { - throw new Error("The Bloom topBar iframe did not expose a frame."); - } + await page.locator("#main-tabs button").filter({ hasText: name }).first(); - await frame.waitForLoadState("domcontentloaded"); - return frame; + await page + .locator("#main-tabs button") + .filter({ hasText: name }) + .first() + .click(); }; export const getWorkspaceTabs = async (): Promise<{ diff --git a/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts b/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts new file mode 100644 index 000000000000..ffca800aa56d --- /dev/null +++ b/src/BloomBrowserUI/utils/ElementAttributeSnapshot.ts @@ -0,0 +1,54 @@ +export type ElementAttributeMap = { + [attributeName: string]: string; +}; + +// Captures the full attribute set from a DOM element so callers can restore it later. +// BookAndPageSettingsDialog uses this to snapshot the current page element when the +// dialog opens, then restore the original page attributes if the user cancels after +// live settings changes have mutated the page. +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/utils/shared.spec.ts b/src/BloomBrowserUI/utils/shared.spec.ts new file mode 100644 index 000000000000..a90695c0c9e4 --- /dev/null +++ b/src/BloomBrowserUI/utils/shared.spec.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + getBloomPageElement, + getPageIframeBody, + whenBloomPageIsReady, +} from "./shared"; + +function setUpPageIframe(): HTMLBodyElement { + const iframe = document.createElement("iframe"); + iframe.id = "page"; + document.body.appendChild(iframe); + return iframe.contentDocument!.body; +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("shared editable page helpers", () => { + it("finds the bloom page inside the page iframe body", () => { + const body = setUpPageIframe(); + const page = document.createElement("div"); + page.className = "bloom-page"; + body.appendChild(page); + + expect(getPageIframeBody()).toBe(body); + expect(getBloomPageElement()).toBe(page); + }); + + it("waits for the bloom page to appear after the iframe body exists", async () => { + const body = setUpPageIframe(); + body.appendChild(document.createElement("div")); + const onReady = vi.fn(); + + const dispose = whenBloomPageIsReady(onReady); + + expect(onReady).not.toHaveBeenCalled(); + + const page = document.createElement("div"); + page.className = "bloom-page"; + body.appendChild(page); + + await vi.waitFor(() => { + expect(onReady).toHaveBeenCalledTimes(1); + }); + expect(onReady).toHaveBeenCalledWith(page); + + dispose(); + }); +}); diff --git a/src/BloomBrowserUI/utils/shared.ts b/src/BloomBrowserUI/utils/shared.ts index 499ee7361ba1..1126a75d09b0 100644 --- a/src/BloomBrowserUI/utils/shared.ts +++ b/src/BloomBrowserUI/utils/shared.ts @@ -29,5 +29,62 @@ export function getPageIframeBody(): HTMLElement | null { return page.contentWindow.document.body; } +export function getBloomPageElement(): HTMLElement | null { + return getPageIframeBody()?.querySelector( + ".bloom-page", + ) as HTMLElement | null; +} + +// We saw one failure where the page iframe and its body already existed, but the editable +// .bloom-page element had not been inserted yet when other React code tried to read it. +// That is only a single observed case, so we do not know how often it happens, but the ordering +// strongly suggests a race between iframe population and code that assumes the live page DOM is +// fully ready as soon as the iframe body exists. Use this helper when the caller truly needs the +// editable page root, rather than treating body readiness as proof that .bloom-page is ready. +export function whenBloomPageIsReady( + onReady: (page: HTMLElement) => void, +): () => void { + let disposed = false; + let observer: MutationObserver | undefined; + + const disconnectObserver = () => { + observer?.disconnect(); + observer = undefined; + }; + + const notifyIfReady = (): boolean => { + const page = getBloomPageElement(); + if (!page || disposed) { + return false; + } + + disconnectObserver(); + onReady(page); + return true; + }; + + if (notifyIfReady()) { + return () => { + disposed = true; + }; + } + + const body = getPageIframeBody(); + if (body) { + observer = new MutationObserver(() => { + notifyIfReady(); + }); + observer.observe(body, { + childList: true, + subtree: true, + }); + } + + return () => { + disposed = true; + disconnectObserver(); + }; +} + //if this is ever changed, be sure to also change it in bloomUI.less export const animateStyleName: string = "bloom-animationPreview"; diff --git a/src/BloomExe/Book/AppearanceSettings.cs b/src/BloomExe/Book/AppearanceSettings.cs index a1d65a638f8b..f521498e5c75 100644 --- a/src/BloomExe/Book/AppearanceSettings.cs +++ b/src/BloomExe/Book/AppearanceSettings.cs @@ -140,6 +140,7 @@ public string FirstPossiblyOffendingCssFile new CssStringVariableDef("marginBox-padding", "margins"), new CssStringVariableDef("multilingual-editable-vertical-gap", "margins"), new CssStringVariableDef("page-background-color", "colors"), + new CssStringVariableDef("page-frame-color", "colors"), new CssStringVariableDef("page-gutter", "margins"), new CssStringVariableDef("page-margin-bottom", "margins"), new CssStringVariableDef("page-margin-left", "margins"), @@ -148,6 +149,8 @@ public string FirstPossiblyOffendingCssFile new CssStringVariableDef("page-split-vertical-gap", "margins"), new CssStringVariableDef("pageNumber-always-left-margin", "page-number"), new CssStringVariableDef("pageNumber-background-color", "page-number"), + new CssStringVariableDef("pageNumber-color", "page-number"), + new CssStringVariableDef("pageNumber-outline-color", "page-number"), new CssStringVariableDef("pageNumber-background-width", "page-number"), new CssStringVariableDef("pageNumber-border-radius", "page-number"), new CssStringVariableDef("pageNumber-bottom", "page-number"), diff --git a/src/BloomExe/Book/HtmlDom.cs b/src/BloomExe/Book/HtmlDom.cs index b2161906ad24..6f0e5fdbb287 100644 --- a/src/BloomExe/Book/HtmlDom.cs +++ b/src/BloomExe/Book/HtmlDom.cs @@ -1886,6 +1886,57 @@ public static void RemoveTemplateEditingMarkup(SafeXmlElement editedPageDiv) public const string musicAttrName = "data-backgroundaudio"; public const string musicVolumeName = musicAttrName + "volume"; + /* Why do we whitelist? Agent says: + Bloom already has history of bad results from copying inline style too broadly. + The nearby element-level comment in BookData.cs:2073 explains one concrete example: + inline display rules copied into saved content caused visibility regressions on covers, while some inline style still had to + be preserved for legitimate cases like image cropping. Same pattern here: don’t ban style completely, + but only allow the specific inline properties that represent intentional persisted settings. + + These five properties are exactly the ones the Page Settings UI writes onto the page element in PageSettingsConfigrPages.tsx:191. + Everything else about page appearance is supposed to come from theme CSS and userModifiedStyles, not from arbitrary leftovers in + the page’s inline style. + + Summary: we whitelist because page.style is a noisy transport layer coming back from the live editor, + not a durable format. Without the whitelist, saving a page would risk persisting accidental editor state and causing hard-to-debug visual + regressions.*/ + private static readonly string[] kPageStylePropertiesToPersist = + { + "--page-background-color", + "--pageNumber-color", + "--pageNumber-outline-color", + "--pageNumber-background-color", + }; + + private static string GetPersistedPageStyleValue(SafeXmlElement editedPageDiv) + { + var style = editedPageDiv.GetAttribute("style"); + if (string.IsNullOrWhiteSpace(style)) + return string.Empty; + + var persistedStyleSegments = style + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(segment => segment.Trim()) + .Where(segment => !string.IsNullOrEmpty(segment)) + .Where(segment => + { + var colonIndex = segment.IndexOf(':'); + if (colonIndex <= 0) + return false; + + var propertyName = segment.Substring(0, colonIndex).Trim(); + return kPageStylePropertiesToPersist.Contains( + propertyName, + StringComparer.OrdinalIgnoreCase + ); + }) + .ToArray(); + + return persistedStyleSegments.Any() + ? string.Join("; ", persistedStyleSegments) + : string.Empty; + } + public static void ProcessPageAfterEditing( SafeXmlElement destinationPageDiv, SafeXmlElement edittedPageDiv @@ -1921,6 +1972,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/Collection/CollectionSettingsDialog.Designer.cs b/src/BloomExe/Collection/CollectionSettingsDialog.Designer.cs index 33d782275fa2..9f0a3186162f 100644 --- a/src/BloomExe/Collection/CollectionSettingsDialog.Designer.cs +++ b/src/BloomExe/Collection/CollectionSettingsDialog.Designer.cs @@ -649,12 +649,12 @@ private void InitializeComponent() this.Controls.Add(this._tab); this._L10NSharpExtender.SetLocalizableToolTip(this, null); this._L10NSharpExtender.SetLocalizationComment(this, null); - this._L10NSharpExtender.SetLocalizingId(this, "CollectionSettingsDialog.CollectionSettingsWindowTitle"); + this._L10NSharpExtender.SetLocalizingId(this, "CollectionSettingsDialog.Title"); this.MaximizeBox = false; this.MinimizeBox = false; this.Name = "CollectionSettingsDialog"; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; - this.Text = "Settings"; + this.Text = "Collection Settings"; this.Load += new System.EventHandler(this.OnLoad); this._tab.ResumeLayout(false); this.tabPage1.ResumeLayout(false); diff --git a/src/BloomExe/Edit/EditingView.cs b/src/BloomExe/Edit/EditingView.cs index c2ac0b42e300..ea9a30b24514 100644 --- a/src/BloomExe/Edit/EditingView.cs +++ b/src/BloomExe/Edit/EditingView.cs @@ -1768,22 +1768,6 @@ public object GetEditFrameSources() // intended for use only by the EditingModel internal Browser Browser => _mainBrowser; - public void SaveAndOpenBookSettingsDialog() - { - _model.SaveThen( - () => - { - // Open the book settings dialog to the context-specific group. - var groupIndex = _model.CurrentPage.IsCoverPage ? 0 : 1; - RunJavascriptAsync( - $"workspaceBundle.showEditViewBookSettingsDialog({groupIndex});" - ); - return _model.CurrentPage.Id; - }, - () => { } // wrong state, do nothing - ); - } - public async Task AddImageFromUrlAsync(string desiredFileNameWithoutExtension, string url) { using (var client = new System.Net.Http.HttpClient()) diff --git a/src/BloomExe/web/controllers/CommonApi.cs b/src/BloomExe/web/controllers/CommonApi.cs index 9e327388e667..a57f47e5b894 100644 --- a/src/BloomExe/web/controllers/CommonApi.cs +++ b/src/BloomExe/web/controllers/CommonApi.cs @@ -237,6 +237,9 @@ private void HandleInstanceInfo(ApiRequest request) var executablePath = Application.ExecutablePath; var cdpPort = Bloom.WebView2Browser.RemoteDebuggingPort; + int? vitePort = ReactControl.TryGetActiveViteDevPort(out var activeVitePort) + ? activeVitePort + : null; request.ReplyWithJson( new { @@ -251,6 +254,7 @@ private void HandleInstanceInfo(ApiRequest request) workspaceTabsUrl = BloomServer.ServerUrlWithBloomPrefixEndingInSlash + "api/workspace/tabs", cdpPort, + vitePort, cdpOrigin = cdpPort.HasValue ? $"http://localhost:{cdpPort.Value}" : null, } ); diff --git a/src/BloomExe/web/controllers/EditingViewApi.cs b/src/BloomExe/web/controllers/EditingViewApi.cs index 8c1bd25967e8..5d1c1072740b 100644 --- a/src/BloomExe/web/controllers/EditingViewApi.cs +++ b/src/BloomExe/web/controllers/EditingViewApi.cs @@ -126,11 +126,6 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) HandleGetCurrentBookId, false ); - apiHandler.RegisterEndpointHandler( - "editView/showBookSettingsDialog", - HandleShowBookSettingsDialog, - true - ); apiHandler.RegisterEndpointHandler( "editView/toggleCustomPageLayout", HandleToggleCustomCover, @@ -271,12 +266,6 @@ private void HandleJumpToPage(ApiRequest request) View.Model.SaveThen(() => pageId, () => { }); } - private void HandleShowBookSettingsDialog(ApiRequest request) - { - request.PostSucceeded(); - View.SaveAndOpenBookSettingsDialog(); - } - /// /// This one is for the snapping function on dragging origami splitters. /// diff --git a/src/BloomTests/Book/AppearanceSettingsTests.cs b/src/BloomTests/Book/AppearanceSettingsTests.cs index ca37c2f3d118..1ee0f1b1d300 100644 --- a/src/BloomTests/Book/AppearanceSettingsTests.cs +++ b/src/BloomTests/Book/AppearanceSettingsTests.cs @@ -111,6 +111,54 @@ public void GetCssOwnPropsDeclaration_WithBrandingAndXmatter_ProducesCorrectCss( ); } + [Test] + public void ToCss_RoundedBorderTheme_LeavesPageNumberDefaultsOnPageNotPseudo() + { + var appearance = new AppearanceSettings(); + appearance.CssThemeName = "rounded-border-ebook"; + + var css = appearance.ToCss(); + + Assert.That( + css, + Does.Contain( + "[class*=\"Device\"].numberedPage:not(.bloom-interactive-page) {" + ) + ); + Assert.That(css, Does.Contain("--pageNumber-background-color: #ffffff;")); + Assert.That(css, Does.Contain("--pageNumber-outline-color: white;")); + Assert.That( + css, + Does.Not.Contain( + "[class*=\"Device\"].numberedPage:not(.bloom-interactive-page)::after {\r\n --pageNumber-bottom: var(--page-margin-bottom);\r\n --pageNumber-top: unset;\r\n --pageNumber-font-size: 11pt;\r\n --pageNumber-border-radius: 50%;\r\n --pageNumber-background-color: #ffffff;" + ) + ); + Assert.That( + css, + Does.Not.Contain( + "[class*=\"Device\"].numberedPage:not(.bloom-interactive-page)::after {\r\n --pageNumber-bottom: var(--page-margin-bottom);\r\n --pageNumber-top: unset;\r\n --pageNumber-font-size: 11pt;\r\n --pageNumber-outline-color: white;" + ) + ); + } + + [Test] + public void ToCss_ZeroMarginTheme_LeavesPageNumberOutlineDefaultOnPageNotPseudo() + { + var appearance = new AppearanceSettings(); + appearance.CssThemeName = "zero-margin-ebook"; + + var css = appearance.ToCss(); + + Assert.That(css, Does.Contain(".numberedPage {")); + Assert.That(css, Does.Contain("--pageNumber-outline-color: white;")); + Assert.That( + css, + Does.Not.Contain( + ".Device16x9Landscape.numberedPage::after {\r\n --pageNumber-bottom: 0mm;\r\n --pageNumber-top: unset;\r\n --pageNumber-font-size: 11pt;\r\n --pageNumber-outline-color: white;" + ) + ); + } + [Test] public void GetCssOwnPropsDeclaration_ItemVisibility_ChildOverrides_UsesChildValue() { @@ -289,6 +337,25 @@ public void ToCss_ContainsSettingsFromJson() Assert.That(fromSettings, Does.Contain("--cover-languageName-show: none;")); } + [Test] + public void ToCss_ContainsPageNumberColorOverridesFromJson() + { + var settings = new AppearanceSettings(); + settings.UpdateFromJson( + @" +{ + ""cssThemeName"": ""default"", + ""pageNumber-color"": ""#123456"", + ""pageNumber-outline-color"": ""#FFFFFF"" +}" + ); + + var css = settings.ToCss(); + + Assert.That(css, Does.Contain("--pageNumber-color: #123456;")); + Assert.That(css, Does.Contain("--pageNumber-outline-color: #FFFFFF;")); + } + [TestCase(@"""pageNumber-position"": ""automatic""", true)] [TestCase(@"""pageNumber-position"": ""left""", true)] [TestCase(@"""pageNumber-position"": ""center""", true)] diff --git a/src/content/appearanceThemes/appearance-theme-default.css b/src/content/appearanceThemes/appearance-theme-default.css index 532dbed270cb..e61a07924180 100644 --- a/src/content/appearanceThemes/appearance-theme-default.css +++ b/src/content/appearanceThemes/appearance-theme-default.css @@ -39,6 +39,9 @@ --pageNumber-background-width: unset; /* for when we need to have a colored background, e.g. a circle */ /* background-color: value in .numberedPage:after to display the page number */ --pageNumber-background-color: transparent; + /* color: value in .numberedPage:after to display the page number */ + --pageNumber-color: black; + --pageNumber-outline-color: white; /* border-radius: value in .numberedPage:after to display the page number */ --pageNumber-border-radius: 0px; /* left: value in .numberedPage.side-left:after to display the page number */ @@ -63,6 +66,9 @@ * The unset keyword removes having any value associated with this variable. */ --pageNumber-always-left-margin: unset; + /* background-color: value in .bloom-page when a theme wants a decorative shell behind the page surface */ + --page-frame-color: unset; + /* background-color: value in .bloom-page */ --page-background-color: white; @@ -102,8 +108,11 @@ /* background-color: value in .numberedPage .marginBox */ /* This is not just for text boxes, but for everything inside the page margins. - (For example, the space between text boxes or the background of partly transparent images.) */ - --marginBox-background-color: white; /* it is not transparent because at edit time we set the parent (the translationGroup) color to show padding + (For example, the space between text boxes or the background of partly transparent images.) + It defaults to the page surface color rather than transparent, because in edit mode we set + the parent translationGroup color to show padding and still need the editable content area + itself to paint like the page surface. */ + --marginBox-background-color: var(--page-background-color); /* padding: value in .numberedPage .marginBox */ --marginBox-padding: 0px; /* border-radius: value in .numberedPage .marginBox */ diff --git a/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css b/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css index 36d7d8f3cf4c..afc5c0d2a631 100644 --- a/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css +++ b/src/content/appearanceThemes/appearance-theme-rounded-border-ebook.css @@ -10,12 +10,13 @@ --page-margin-bottom: 2mm; --page-margin-left: 2mm; --page-margin-right: 2mm; - --page-background-color: #2e2e2e; /* almost black */ + --page-frame-color: #2e2e2e; /* almost black */ + --page-background-color: #ffffff; --marginBox-border-radius: 15px; --marginBox-padding: 1.5mm; - --marginBox-background-color: #ffffff; /* white */ - /* set to 0 because our marginBox is white but background is dark. This has the effect of increasing the text padding on the edges. */ + /* set to 0 because the visible page frame is dark while the reading surface is white. + This has the effect of increasing the text padding on the edges. */ --page-and-marginBox-are-same-color-multiplicand: 0; /* move so that page number doesn't it hide it if the text box is in the lower left */ @@ -26,16 +27,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 */ + --pageNumber-outline-color: white; } [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..aebe567cd302 100644 --- a/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css +++ b/src/content/appearanceThemes/appearance-theme-zero-margin-ebook.css @@ -17,8 +17,9 @@ 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: transparent; + --pageNumber-outline-color: white; --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 { @@ -37,12 +38,10 @@ Note that hiding the page numbers is done by a setting in appearance.json, not h } .Device16x9Landscape.numberedPage::after { --pageNumber-bottom: 0mm; + --pageNumber-top: unset; --pageNumber-font-size: 11pt; - - border-radius: 50%; - --pageNumber-background-color: #ffffff; - --pageNumber-background-width: 33px; + --pageNumber-background-width: 33px; /* TODO; we tried --pageNumber-left-margin and it did nothing */ --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/basePage.less b/src/content/bookLayout/basePage.less index f4b8e82620c5..e57bc093d8c0 100644 --- a/src/content/bookLayout/basePage.less +++ b/src/content/bookLayout/basePage.less @@ -754,7 +754,10 @@ div.bloom-page.coverColor { display: block; box-sizing: border-box; - background-color: var(--page-background-color) !important; + background-color: var( + --page-frame-color, + var(--page-background-color) + ) !important; // editMode.less may change this when we're editing .split-pane-component-inner > .bloom-translationGroup { @@ -950,6 +953,61 @@ The buffer would be absent if the marginBox had a border or the page has a backg padding-bottom: var(--topLevel-text-padding-bottom); } + /* +I can't repro the problem this was fixing, so for now I'm commenting it out. + + // Theme-switch testing exposed that these horizontal-then-vertical corner cases were + // missing from the existing split-pane padding rules, so zero-margin pages lost the + // intended top-level text padding on those layouts. + & + > .split-pane.horizontal-percent + > .split-pane-component.position-top + > .split-pane.vertical-percent + > .split-pane-component.position-left + > .split-pane-component-inner + > .bloom-translationGroup { + padding-bottom: var(--topLevel-text-padding-bottom); + padding-right: var(--topLevel-text-padding-right); + .mixinLeftTgPadding; + .mixinTopTgPadding; + } + & + > .split-pane.horizontal-percent + > .split-pane-component.position-top + > .split-pane.vertical-percent + > .split-pane-component.position-right + > .split-pane-component-inner + > .bloom-translationGroup { + padding-bottom: var(--topLevel-text-padding-bottom); + padding-left: var(--topLevel-text-padding-left); + .mixinRightTgPadding; + .mixinTopTgPadding; + } + & + > .split-pane.horizontal-percent + > .split-pane-component.position-bottom + > .split-pane.vertical-percent + > .split-pane-component.position-left + > .split-pane-component-inner + > .bloom-translationGroup { + padding-top: var(--topLevel-text-padding-top); + padding-right: var(--topLevel-text-padding-right); + .mixinLeftTgPadding; + .mixinBottomTgPadding; + } + & + > .split-pane.horizontal-percent + > .split-pane-component.position-bottom + > .split-pane.vertical-percent + > .split-pane-component.position-right + > .split-pane-component-inner + > .bloom-translationGroup { + padding-top: var(--topLevel-text-padding-top); + padding-left: var(--topLevel-text-padding-left); + .mixinRightTgPadding; + .mixinBottomTgPadding; + } +*/ // This one handles the text box at the bottom left of a Big Video Diglot. // Review: it's not obvious what to do about the outer edges here. In the simpler cases, // we DO have outer-edge padding when there is no other margin and the top-level split is vertical. diff --git a/src/content/bookLayout/pageNumbers.less b/src/content/bookLayout/pageNumbers.less index 254a9ca3a3e6..f764900b1972 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,11 @@ bottom: var(--pageNumber-bottom); top: var(--pageNumber-top); background-color: var(--pageNumber-background-color); + color: var(--pageNumber-color); + + -webkit-text-stroke: 1px var(--pageNumber-outline-color); + paint-order: stroke fill; + border-radius: var(--pageNumber-border-radius); z-index: 1000; // These are needed to get the number centered in a circle. They have