forked from akai/readest
ff96c6d3f7
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>
279 lines
9.4 KiB
TypeScript
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,
|
|
};
|
|
}
|