fix(reader): make annotation toolbar customization apply to all books (#4760)

The customized annotation toolbar only took effect in the book where it
was changed, instead of applying to every book.

Root cause: serializeConfig decided which per-book view settings to
persist as overrides using a reference check (globalViewSettings[key]
!== value). It deep-clones the config first, so array-valued settings
like annotationToolbarItems are always a fresh reference and were stored
as a per-book override on every save (each progress autosave). On reopen
the per-book override shadowed the global value, so a global toolbar
change never reached already-opened books.

Compare view-setting values by content, not reference, so array/object
settings equal to the global value are no longer persisted per-book. This
also fixes the same latent issue for paragraphMode, proofreadRules,
ttsHighlightOptions and noteExportConfig.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-24 21:48:03 +08:00
committed by GitHub
parent ac6249cbc3
commit 7da5f83213
2 changed files with 47 additions and 1 deletions
@@ -108,6 +108,43 @@ describe('BookConfig serialization', () => {
expect(tombstone.deletedAt).toBeTruthy();
});
it('does not persist an array view setting that equals the global value', () => {
// Array/object view settings must be compared by value, not reference —
// otherwise annotationToolbarItems (an array) is stored as a per-book override on
// every save, shadowing later global changes (the customize-toolbar bug).
const global = {
...globalViewSettings,
annotationToolbarItems: ['highlight', 'annotate', 'copy'],
} as unknown as ViewSettings;
const config: BookConfig = {
updatedAt: 1,
// Same content as global but a distinct array reference, as produced by the
// load -> merge -> serialize round-trip.
viewSettings: {
annotationToolbarItems: ['highlight', 'annotate', 'copy'],
} as Partial<ViewSettings>,
};
const parsed = JSON.parse(serializeConfig(config, global, defaultSearchConfig));
expect(parsed.viewSettings.annotationToolbarItems).toBeUndefined();
});
it('persists an array view setting that differs from the global value', () => {
const global = {
...globalViewSettings,
annotationToolbarItems: ['highlight', 'annotate', 'copy'],
} as unknown as ViewSettings;
const config: BookConfig = {
updatedAt: 1,
viewSettings: { annotationToolbarItems: ['copy'] } as Partial<ViewSettings>,
};
const parsed = JSON.parse(serializeConfig(config, global, defaultSearchConfig));
expect(parsed.viewSettings.annotationToolbarItems).toEqual(['copy']);
});
it('does not migrate annotations when schemaVersion is already 2', () => {
const config = deserializeConfig(
JSON.stringify({
+10 -1
View File
@@ -14,6 +14,15 @@ export const serializeRawConfig = (config: Partial<BookConfig>): string => {
return JSON.stringify(stampBookConfigSchema(config));
};
// Compare a per-book view-setting value with the global one by value, not
// reference. serializeConfig deep-clones the config first, so array/object
// settings (e.g. annotationToolbarItems) are always fresh references; a reference
// check would persist them as per-book overrides even when identical to global,
// which then shadows later global changes on reopen. Primitives short-circuit;
// the JSON compare covers arrays and plain config objects (all JSON-serializable).
const isSameViewSettingValue = (a: unknown, b: unknown): boolean =>
a === b || JSON.stringify(a) === JSON.stringify(b);
export const serializeConfig = (
config: BookConfig,
globalViewSettings: ViewSettings,
@@ -35,7 +44,7 @@ export const serializeConfig = (
const searchConfig = (config.searchConfig ?? {}) as Partial<BookSearchConfig>;
config.viewSettings = Object.entries(viewSettings).reduce(
(acc: Partial<Record<keyof ViewSettings, unknown>>, [key, value]) => {
if (globalViewSettings[key as keyof ViewSettings] !== value) {
if (!isSameViewSettingValue(globalViewSettings[key as keyof ViewSettings], value)) {
acc[key as keyof ViewSettings] = value;
}
return acc;