feat(annotations): unify highlights and annotations into one record (#3870, #4511) (#4647)

A highlight and its note are now a single BookNote. Adding a note attaches
it to the highlight at that CFI (or creates one with the current global
style) instead of creating a second record, and a unified record renders as
both a highlight overlay and a note bubble.

- onDrawAnnotation chooses the draw kind from the overlay value prefix
  (cfi -> highlight, NOTE_PREFIX -> bubble) instead of annotation.note, so a
  record with both a style and a note draws both. Fixes notes synced from
  KOReader losing their highlight (#4511).
- handleSaveNote updates the existing annotation at the CFI rather than
  pushing a new record (#3870); re-styling preserves the note.
- unifyAnnotations migration (book config schema v1 -> v2, run in
  deserializeConfig) collapses existing split highlight+note records into one
  survivor and tombstones the redundant record (deletedAt) so the merge syncs
  to the cloud and KOReader.
- Sidebar: a note's quoted highlight text uses the theme foreground so it
  stays legible on the highlight background.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-18 21:37:46 +08:00
committed by GitHub
parent 38a6d3d9ba
commit ff96c6d3f7
10 changed files with 436 additions and 26 deletions
@@ -56,7 +56,9 @@ import { TransformContext } from '@/services/transformers/types';
import { transformContent } from '@/services/transformService';
import {
buildTTSSentenceHighlight,
decideAnnotationDraw,
getHighlightColorHex,
mergeRestyledAnnotation,
removeBookNoteOverlays,
} from '../../utils/annotatorUtil';
import { buildAnnotationIndex, selectLocationAnnotations } from '../../utils/annotationIndex';
@@ -495,21 +497,27 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
const detail = (event as CustomEvent).detail;
const { draw, annotation, doc, range } = detail;
const { style, color } = annotation as BookNote;
const value = (annotation as BookNote & { value?: string }).value;
const hexColor = getHighlightColorHex(settings, color);
const einkBgColor = isDarkMode ? '#000000' : '#ffffff';
const einkFgColor = isDarkMode ? '#ffffff' : '#000000';
if (annotation.note) {
// Choose what to draw from the overlay's `value` (cfi vs NOTE_PREFIX+cfi),
// not from `annotation.note`: a unified record (style + note) is added as
// two overlays and must draw a highlight for the cfi overlay AND a bubble
// for the note overlay. Keying off `note` drew only the bubble (#4511).
const kind = decideAnnotationDraw(value, style);
if (kind === 'bubble') {
const { defaultView } = doc;
const node = range.startContainer;
const el = node.nodeType === 1 ? node : node.parentElement;
const { writingMode } = defaultView.getComputedStyle(el);
draw(Overlayer.bubble, { writingMode });
} else if (style === 'highlight') {
} else if (kind === 'highlight') {
draw(Overlayer.highlight, {
color: isBwEink ? einkBgColor : hexColor,
vertical: viewSettings.vertical,
});
} else if (['underline', 'squiggly'].includes(style as string)) {
} else if (kind === 'underline' || kind === 'squiggly') {
const { defaultView } = doc;
const node = range.startContainer;
const el = node.nodeType === 1 ? node : node.parentElement;
@@ -522,7 +530,7 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
const padding = viewSettings.vertical
? (lineHeightValue - fontSizeValue) / 2 - strokeWidth + verticalCompensation
: (lineHeightValue - fontSizeValue) / 2 - strokeWidth + horizontalCompensation;
draw(Overlayer[style as keyof typeof Overlayer], {
draw(Overlayer[kind], {
writingMode,
color: isBwEink ? einkFgColor : hexColor,
padding,
@@ -1089,15 +1097,17 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
views.forEach((view) => removeGlobalAnnotationOverlays(view, existing));
}
if (update) {
annotation.id = existing.id;
// Carry the existing `global` flag forward — toggling color/style
// shouldn't silently demote a global highlight back to single-range.
if (existing.global) annotation.global = true;
annotations[existingIndex] = annotation;
views.forEach((view) => view?.addAnnotation(annotation));
if (annotation.global) {
// Preserve the note/text/createdAt and the `global` flag of the existing
// record so a restyle (color/style change) of a unified annotation
// doesn't wipe its note or silently demote a global highlight. The note
// bubble overlay (NOTE_PREFIX) isn't torn down above, so it persists; we
// only redraw the highlight overlay (value = cfi).
const merged = mergeRestyledAnnotation(existing, annotation);
annotations[existingIndex] = merged;
views.forEach((view) => view?.addAnnotation(merged));
if (merged.global) {
views.forEach((view) => {
if (view) expandAllRenderedSections(view, annotation);
if (view) expandAllRenderedSections(view, merged);
});
}
} else {