Files
readest/apps/readest-app/src/app/reader/utils/annotatorUtil.ts
T
Huang Xin ff96c6d3f7 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>
2026-06-18 15:37:46 +02:00

279 lines
9.4 KiB
TypeScript

import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import { BookNote, DEFAULT_HIGHLIGHT_COLORS, HighlightColor, HighlightStyle } from '@/types/book';
import { uniqueId } from '@/utils/misc';
import { SystemSettings } from '@/types/settings';
import { FoliateView, NOTE_PREFIX } from '@/types/view';
import { Point, snapRangeToWords } from '@/utils/sel';
export const isDefaultHighlightColor = (
color: HighlightColor,
): color is (typeof DEFAULT_HIGHLIGHT_COLORS)[number] => {
return (DEFAULT_HIGHLIGHT_COLORS as readonly string[]).includes(color);
};
export const getHighlightColorHex = (
settings: SystemSettings,
color?: HighlightColor,
): string | undefined => {
if (!color) return undefined;
if (color.startsWith('#')) return color;
const customColors = settings.globalReadSettings.customHighlightColors;
return customColors?.[color] ?? HIGHLIGHT_COLOR_HEX[color];
};
/**
* Returns a user-defined label for the given color, or `undefined` when none is set.
* Callers that want to fall back to a translated default name should handle that in
* the component layer (where `useTranslation` is available).
*/
export const getHighlightColorLabel = (
settings: SystemSettings,
color: HighlightColor,
): string | undefined => {
const { defaultHighlightLabels, userHighlightColors } = settings.globalReadSettings;
if (color.startsWith('#')) {
const hex = color.trim().toLowerCase();
const entry = userHighlightColors?.find((c) => c.hex === hex);
return entry?.label?.trim() || undefined;
}
if (isDefaultHighlightColor(color)) {
return defaultHighlightLabels?.[color]?.trim() || undefined;
}
return undefined;
};
export function getExternalDragHandle(
currentStart: Point,
currentEnd: Point,
externalDragPoint?: Point | null,
): 'start' | 'end' | null {
if (!externalDragPoint) return null;
const distToStart = Math.hypot(
externalDragPoint.x - currentStart.x,
externalDragPoint.y - currentStart.y,
);
const distToEnd = Math.hypot(
externalDragPoint.x - currentEnd.x,
externalDragPoint.y - currentEnd.y,
);
return distToStart < distToEnd ? 'start' : 'end';
}
export function toParentViewportPoint(doc: Document, x: number, y: number): Point {
const frameElement = doc.defaultView?.frameElement;
const frameRect = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 };
return { x: x + frameRect.left, y: y + frameRect.top };
}
export interface HandlePositions {
start: Point;
end: Point;
}
/**
* Window-coordinate positions for a pair of range-edit drag handles: the
* start handle anchors at the leading edge of the range's first line rect,
* the end handle at the trailing edge of its last one.
*/
export function getHandlePositionsFromRange(
bookKey: string,
range: Range,
isVertical: boolean,
): HandlePositions | null {
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
if (!gridFrame) return null;
const rects = Array.from(range.getClientRects());
if (rects.length === 0) return null;
const firstRect = rects[0]!;
const lastRect = rects[rects.length - 1]!;
const frameElement = range.commonAncestorContainer.ownerDocument?.defaultView?.frameElement;
const frameRect = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 };
return {
start: {
x: frameRect.left + (isVertical ? firstRect.right : firstRect.left),
y: frameRect.top + firstRect.top,
},
end: {
x: frameRect.left + (isVertical ? lastRect.left : lastRect.right),
y: frameRect.top + lastRect.bottom,
},
};
}
/**
* Build a word-snapped Range between two window-coordinate points by
* hit-testing every rendered section document. Returns the document and
* section index the range landed in, or `null` when neither point maps
* into the same document.
*/
export function buildRangeFromPoints(
view: FoliateView | null,
startPoint: Point,
endPoint: Point,
): { range: Range; index: number; doc: Document } | null {
const contents = view?.renderer.getContents();
if (!contents || contents.length === 0) return null;
// the point is from viewport, need to adjust to each content's coordinate
const findPositionAtPoint = (doc: Document, x: number, y: number) => {
const frameElement = doc.defaultView?.frameElement;
const frameRect = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 };
const adjustedX = x - frameRect.left;
const adjustedY = y - frameRect.top;
if (doc.caretPositionFromPoint) {
const pos = doc.caretPositionFromPoint(adjustedX, adjustedY);
if (pos) return { node: pos.offsetNode, offset: pos.offset };
}
if (doc.caretRangeFromPoint) {
const range = doc.caretRangeFromPoint(adjustedX, adjustedY);
if (range) return { node: range.startContainer, offset: range.startOffset };
}
return null;
};
for (const content of contents) {
const { doc, index } = content;
if (!doc) continue;
const startPos = findPositionAtPoint(doc, startPoint.x, startPoint.y);
const endPos = findPositionAtPoint(doc, endPoint.x, endPoint.y);
if (!startPos || !endPos) continue;
const range = doc.createRange();
try {
const positionComparison = startPos.node.compareDocumentPosition(endPos.node);
const needsSwap =
positionComparison & Node.DOCUMENT_POSITION_PRECEDING ||
(startPos.node === endPos.node && startPos.offset > endPos.offset);
if (needsSwap) {
range.setStart(endPos.node, endPos.offset);
range.setEnd(startPos.node, startPos.offset);
} else {
range.setStart(startPos.node, startPos.offset);
range.setEnd(endPos.node, endPos.offset);
}
if (range.collapsed) {
return null;
}
snapRangeToWords(range);
} catch (e) {
console.warn('Failed to create range:', e);
return null;
}
return { range, index: index ?? 0, doc };
}
return null;
}
/**
* Remove any overlays drawn for a BookNote from the given view.
*
* A single BookNote can have up to two overlays attached:
* - a highlight/underline/squiggly overlay (keyed by the raw CFI)
* - a note bubble overlay (keyed by `${NOTE_PREFIX}${cfi}`)
*
* The set of overlays drawn is defined by the progress-sync effect in
* Annotator.tsx, and this helper mirrors those filters so that deleting
* an annotation from the sidebar clears every overlay that was drawn
* for it, not just the note bubble.
*/
export function removeBookNoteOverlays(view: FoliateView | null, note: BookNote): void {
if (!view) return;
if (note.type !== 'annotation') return;
if (note.style) {
view.addAnnotation({ ...note, value: note.cfi }, true);
}
if (note.note && note.note.trim().length > 0) {
view.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` }, true);
}
}
/**
* Build a persistent highlight BookNote for a TTS-spoken sentence, or return
* `null` when one already exists at the same CFI (idempotent — pressing the
* hotkey twice on the same sentence must not create a duplicate).
*
* `now` is injected so the result is deterministic for tests. A soft-deleted
* note (`deletedAt`) or a non-annotation note (e.g. a bookmark) at the same CFI
* does not block creation — it mirrors the live-annotation predicate used by
* the selection-based highlight path in Annotator.tsx.
*/
export function buildTTSSentenceHighlight(
annotations: BookNote[],
params: {
cfi: string;
text: string;
style: HighlightStyle;
color: HighlightColor;
page?: number;
},
now: number,
): BookNote | null {
const exists = annotations.some(
(a) => a.cfi === params.cfi && a.type === 'annotation' && a.style && !a.deletedAt,
);
if (exists) return null;
return {
id: uniqueId(),
type: 'annotation',
note: '',
createdAt: now,
updatedAt: now,
...params,
};
}
export type AnnotationDrawKind = 'bubble' | 'highlight' | 'underline' | 'squiggly' | 'none';
/**
* Decide what an overlay should draw for an annotation. The bubble vs.
* highlight choice keys off the overlay's `value` prefix — NOT `annotation.note`
* — so a single unified record draws BOTH its highlight overlay (value = cfi)
* and its note bubble (value = `${NOTE_PREFIX}${cfi}`).
*/
export function decideAnnotationDraw(
value: string | undefined,
style: HighlightStyle | undefined,
): AnnotationDrawKind {
if (value?.startsWith(NOTE_PREFIX)) return 'bubble';
if (style === 'highlight') return 'highlight';
if (style === 'underline' || style === 'squiggly') return style;
return 'none';
}
/**
* Index of the live (`!deletedAt`) annotation record at `cfi`, or -1. Used when
* adding a note so it attaches to the existing highlight instead of creating a
* second record at the same position.
*/
export function findAnnotationAtCfi(booknotes: BookNote[], cfi: string): number {
return booknotes.findIndex(
(note) => note.type === 'annotation' && note.cfi === cfi && !note.deletedAt,
);
}
/**
* Merge a freshly-built restyle (`restyled`, carrying the new style/color) onto
* an `existing` annotation, preserving the parts a restyle must not lose: the
* record id, its note text, the selected text, the original creation time, and
* the `global` flag. Without preserving `note`, recoloring a unified annotation
* would wipe the note.
*/
export function mergeRestyledAnnotation(existing: BookNote, restyled: BookNote): BookNote {
return {
...restyled,
id: existing.id,
createdAt: existing.createdAt,
note: existing.note,
text: existing.text ?? restyled.text,
global: existing.global || restyled.global,
};
}