From 140b71ee30479cd098728a7853f830254b3a9774 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 22 Jun 2026 23:51:23 +0800 Subject: [PATCH] feat(dictionary): add adjustable dictionary popup font size (#4443) (#4734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose `::part(dict-content)` on the MDict shadow content and add a dictionary popup font-size setting (Settings → Language → Dictionaries), independent of the main reading view. - mdictProvider: tag the in-shadow body with `part="dict-content"` and a stable `dict-shadow-host` class so the popup's `::part()` rule can reach across the shadow boundary — MDict is the only provider that renders into a shadow root, so ordinary popup CSS can't touch it. - DictionarySettings.fontScale (default 1) with setFontScale + load-merge; synced cross-device via the `dictionarySettings.fontScale` whitelist entry. - DictionaryResultsView drives `--dict-font-scale` + `data-dict-content` on each per-tab container. globals.css re-bases the light-DOM Tailwind text utilities to `em` within that scope and sizes the MDict shadow body via `::part(dict-content)`, so every provider scales from one lever. - SettingsSelect control (85–175%) in the Dictionaries panel. Tests: jsdom unit tests (part attribute, store fontScale, sync whitelist) plus a real-Chromium browser test for the em-rebasing + `::part` + custom- property-inheritance CSS contract jsdom cannot model. Co-authored-by: Claude Opus 4.8 (1M context) --- .../dict-popup-font-size.browser.test.ts | 117 ++++++++++++++++++ .../dictionaries/mdictProvider.test.ts | 38 ++++++ .../services/sync/adapters/settings.test.ts | 2 + .../store/custom-dictionary-store.test.ts | 71 +++++++++++ .../annotator/DictionaryResultsView.tsx | 10 ++ .../settings/CustomDictionaries.tsx | 35 +++++- .../dictionaries/providers/mdictProvider.ts | 10 +- .../src/services/dictionaries/types.ts | 8 ++ .../src/services/sync/adapters/settings.ts | 1 + .../src/store/customDictionaryStore.ts | 10 ++ apps/readest-app/src/styles/globals.css | 39 ++++++ 11 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/dictionaries/dict-popup-font-size.browser.test.ts diff --git a/apps/readest-app/src/__tests__/services/dictionaries/dict-popup-font-size.browser.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/dict-popup-font-size.browser.test.ts new file mode 100644 index 00000000..095d7cec --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/dict-popup-font-size.browser.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +// Real-browser regression test for readest#4443 (adjustable dictionary popup +// font size). The feature hangs entirely on CSS behaviour jsdom cannot model: +// 1. Tailwind `text-*` utilities re-based to `em` so they ride a scaled +// `font-size` root on `[data-dict-content]`. +// 2. `::part(dict-content)` reaching into the MDict shadow root, with the +// `--dict-font-scale` custom property inheriting across the boundary. +// Both need a layout engine + a real Shadow DOM + Custom Highlight-free part +// styling, so this lives in the browser lane. +// +// The rules below mirror the `[data-dict-content]` block in +// `src/styles/globals.css`; keep them byte-identical to what ships. +const POPUP_FONT_RULES = ` +[data-dict-content] { + font-size: calc(var(--dict-font-scale, 1) * 1em); +} +[data-dict-content] .text-xs { font-size: 0.75em; } +[data-dict-content] .text-sm { font-size: 0.875em; } +[data-dict-content] .text-base { font-size: 1em; } +[data-dict-content] .text-lg { font-size: 1.125em; } +[data-dict-content] .dict-shadow-host::part(dict-content) { + font-size: calc(var(--dict-font-scale, 1) * 0.875rem); +} +`; + +let style: HTMLStyleElement | null = null; +let root: HTMLElement | null = null; + +const px = (el: Element): number => parseFloat(getComputedStyle(el).fontSize); + +/** + * Build the exact DOM the popup produces for a card: a `[data-dict-content]` + * content root holding a light-DOM headword + body (e.g. the DICT provider) + * and an MDict shadow host whose shadow body carries `part="dict-content"`. + */ +const mountCard = (scale: number) => { + root = document.createElement('div'); + // The popup's surrounding chrome inherits the document base; force a known + // 16px base so `rem`/`em` math is deterministic regardless of UA defaults. + root.style.fontSize = '16px'; + root.innerHTML = ` +
+

word

+
a definition
+
+
`; + document.body.appendChild(root); + + const host = root.querySelector('[data-testid="host"]') as HTMLElement; + const shadow = host.attachShadow({ mode: 'open' }); + const body = document.createElement('div'); + body.dataset['dictKind'] = 'mdict'; + body.setAttribute('part', 'dict-content'); + body.textContent = 'shadow definition'; + shadow.appendChild(body); + + return { + headword: root.querySelector('[data-testid="headword"]') as HTMLElement, + lightBody: root.querySelector('[data-testid="light-body"]') as HTMLElement, + shadowBody: body, + }; +}; + +describe('dictionary popup font size (#4443)', () => { + afterEach(() => { + root?.remove(); + root = null; + style?.remove(); + style = null; + }); + + it('renders default sizes at scale 1 and scales every region linearly', () => { + style = document.createElement('style'); + style.textContent = POPUP_FONT_RULES; + document.head.appendChild(style); + + const base = mountCard(1); + // Defaults match the pre-feature look exactly: text-lg=18, text-sm=14, + // and the MDict shadow body keeps its 0.875rem (14px) base. + expect(px(base.headword)).toBeCloseTo(18, 1); + expect(px(base.lightBody)).toBeCloseTo(14, 1); + expect(px(base.shadowBody)).toBeCloseTo(14, 1); + root!.remove(); + + const big = mountCard(1.5); + // Light-DOM utilities ride the scaled `[data-dict-content]` root... + expect(px(big.headword)).toBeCloseTo(27, 1); // 18 * 1.5 + expect(px(big.lightBody)).toBeCloseTo(21, 1); // 14 * 1.5 + // ...and `::part()` carries the same factor across the shadow boundary, + // proving --dict-font-scale inherits into the shadow tree. + expect(px(big.shadowBody)).toBeCloseTo(21, 1); // 14 * 1.5 + }); + + it('leaves the shadow body at its default when the part hook is absent', () => { + // Without the part attribute the rule cannot reach the shadow content — + // this guards against a future refactor dropping the hook silently. + style = document.createElement('style'); + style.textContent = POPUP_FONT_RULES; + document.head.appendChild(style); + + root = document.createElement('div'); + root.style.fontSize = '16px'; + root.innerHTML = `
+
+
`; + document.body.appendChild(root); + const host = root.querySelector('[data-testid="host"]') as HTMLElement; + const shadow = host.attachShadow({ mode: 'open' }); + const body = document.createElement('div'); + body.style.fontSize = '0.875rem'; // its own author style, no part exposed + body.textContent = 'unreachable'; + shadow.appendChild(body); + + expect(px(body)).toBeCloseTo(14, 1); // unscaled — the ::part rule can't bind + }); +}); diff --git a/apps/readest-app/src/__tests__/services/dictionaries/mdictProvider.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/mdictProvider.test.ts index 1abce1cb..d053392d 100644 --- a/apps/readest-app/src/__tests__/services/dictionaries/mdictProvider.test.ts +++ b/apps/readest-app/src/__tests__/services/dictionaries/mdictProvider.test.ts @@ -846,6 +846,44 @@ describe('mdictProvider', () => { } }); + it('exposes part="dict-content" on the in-shadow body so outer CSS can size it via ::part()', async () => { + const jsmdict = await import('js-mdict'); + const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX); + type FakeMDX = ReturnType extends Promise ? T : never; + jsmdict.MDX.create = async () => + ({ + meta: { encrypt: 0 }, + header: {}, + lookup: async (word: string) => ({ + keyText: word, + definition: `

def

`, + }), + }) as unknown as FakeMDX; + + try { + const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() }); + const container = document.createElement('div'); + await provider.lookup('hello', { + signal: new AbortController().signal, + container, + }); + + const shadow = getMdictShadow(container); + // The content root carries BOTH the data-dict-kind hook and the + // shadow `part` so the popup's `::part(dict-content)` font-size rule + // can reach across the shadow boundary (it otherwise can't). + const body = shadow.querySelector('[data-dict-kind="mdict"]'); + expect(body).not.toBeNull(); + expect(body!.getAttribute('part')).toBe('dict-content'); + // The host must be selectable from the outer tree for the `::part()` + // rule's host selector to match. + const host = container.lastElementChild as HTMLElement; + expect(host.classList.contains('dict-shadow-host')).toBe(true); + } finally { + jsmdict.MDX.create = origMDXCreate; + } + }); + it('keeps the auto-prepended headword when the dict body has a different h1 text', async () => { const jsmdict = await import('js-mdict'); const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX); diff --git a/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts b/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts index f9676ead..6b0d673a 100644 --- a/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts @@ -141,6 +141,8 @@ describe('SETTINGS_WHITELIST', () => { expect(SETTINGS_WHITELIST).toContain('dictionarySettings.providerOrder'); expect(SETTINGS_WHITELIST).toContain('dictionarySettings.providerEnabled'); expect(SETTINGS_WHITELIST).toContain('dictionarySettings.webSearches'); + // Dictionary popup font size (#4443) follows the user across devices. + expect(SETTINGS_WHITELIST).toContain('dictionarySettings.fontScale'); }); test('does NOT sync dictionarySettings.defaultProviderId (per-device last-used tab)', () => { diff --git a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts index 38975846..86a767b1 100644 --- a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts +++ b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts @@ -695,3 +695,74 @@ describe('customDictionaryStore — loadCustomDictionaries reconciliation', () = expect('imp-tombstoned' in after.providerEnabled).toBe(false); }); }); + +describe('customDictionaryStore — fontScale (dictionary popup font size, #4443)', () => { + beforeEach(() => { + vi.clearAllMocks(); + useCustomDictionaryStore.setState({ + dictionaries: [], + settings: { + providerOrder: ['local-x'], + providerEnabled: { 'local-x': true }, + webSearches: [], + }, + }); + }); + + it('setFontScale updates the in-memory setting', () => { + const { setFontScale } = useCustomDictionaryStore.getState(); + setFontScale(1.3); + expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1.3); + }); + + it('applyRemoteDictionarySettings overlays a remote fontScale patch', () => { + const { applyRemoteDictionarySettings } = useCustomDictionaryStore.getState(); + applyRemoteDictionarySettings({ fontScale: 1.5 }); + expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1.5); + }); + + it('loadCustomDictionaries defaults fontScale to 1 when the persisted settings omit it', async () => { + type SettingsState = ReturnType; + useSettingsStore.setState({ + settings: { + customDictionaries: [], + dictionarySettings: { + providerOrder: ['builtin:wikipedia'], + providerEnabled: { 'builtin:wikipedia': true }, + webSearches: [], + }, + } as unknown as SettingsState['settings'], + } as unknown as SettingsState); + + const fakeAppService = { exists: vi.fn().mockResolvedValue(false) }; + const fakeEnv = { + getAppService: () => Promise.resolve(fakeAppService), + } as unknown as EnvConfigType; + + await useCustomDictionaryStore.getState().loadCustomDictionaries(fakeEnv); + expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1); + }); + + it('loadCustomDictionaries preserves a persisted fontScale', async () => { + type SettingsState = ReturnType; + useSettingsStore.setState({ + settings: { + customDictionaries: [], + dictionarySettings: { + providerOrder: ['builtin:wikipedia'], + providerEnabled: { 'builtin:wikipedia': true }, + webSearches: [], + fontScale: 1.15, + }, + } as unknown as SettingsState['settings'], + } as unknown as SettingsState); + + const fakeAppService = { exists: vi.fn().mockResolvedValue(false) }; + const fakeEnv = { + getAppService: () => Promise.resolve(fakeAppService), + } as unknown as EnvConfigType; + + await useCustomDictionaryStore.getState().loadCustomDictionaries(fakeEnv); + expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1.15); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/annotator/DictionaryResultsView.tsx b/apps/readest-app/src/app/reader/components/annotator/DictionaryResultsView.tsx index 4e29c436..8c6ab7b1 100644 --- a/apps/readest-app/src/app/reader/components/annotator/DictionaryResultsView.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/DictionaryResultsView.tsx @@ -49,6 +49,8 @@ export interface DictionaryResultsState { resolveWebSearchUrl: (id: string) => string | undefined; onWebSearchClickTauri: (e: React.MouseEvent, id: string) => void; noProvidersAtAll: boolean; + /** Dictionary popup font-size multiplier (#4443); `1` = default sizes. */ + fontScale: number; } /** @@ -312,6 +314,7 @@ export function useDictionaryResults({ resolveWebSearchUrl, onWebSearchClickTauri, noProvidersAtAll, + fontScale: settings.fontScale ?? 1, }; } @@ -377,6 +380,7 @@ export const DictionaryResultsBody: React.FC = ({ resolveWebSearchUrl, onWebSearchClickTauri, noProvidersAtAll, + fontScale, }) => { const _ = useTranslation(); return ( @@ -440,6 +444,12 @@ export const DictionaryResultsBody: React.FC = ({
void; @@ -254,6 +264,7 @@ const CustomDictionaries: React.FC = ({ onBack }) => { updateDictionary, reorder, setEnabled, + setFontScale, addWebSearch, updateWebSearch, removeWebSearch, @@ -284,6 +295,11 @@ const CustomDictionaries: React.FC = ({ onBack }) => { cancelled = true; }; }, [appService]); + const handleFontScaleChange = async (e: React.ChangeEvent) => { + setFontScale(Number(e.target.value)); + await saveCustomDictionaries(envConfig); + }; + const handleResetLookupApp = async () => { await clearRememberedLookupApp(); setRememberedLookupApp(null); @@ -769,6 +785,23 @@ const CustomDictionaries: React.FC = ({ onBack }) => {
+ + + + + +