forked from akai/readest
ef603852b7
Add a "Highlight Current Sentence" keyboard action (default Shift+M, in the Text to Speech shortcut section) that persists the sentence TTS is reading aloud as a normal highlight using the user's default style/color — no text selection, eyes-off, silent, and idempotent (a repeat press on the same sentence is a no-op rather than a duplicate). Flow: the shortcut handler in useBookShortcuts dispatches tts-highlight-sentence → useTTSControl (which owns the TTSController) resolves the current sentence via the new TTSController.getSpokenSentence() and relays create-tts-highlight → Annotator builds the BookNote with the pure, unit-tested buildTTSSentenceHighlight helper and persists/renders it like any other highlight. Closes #4085 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
300 lines
9.3 KiB
TypeScript
300 lines
9.3 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import {
|
|
buildTTSSentenceHighlight,
|
|
getExternalDragHandle,
|
|
getHighlightColorLabel,
|
|
removeBookNoteOverlays,
|
|
toParentViewportPoint,
|
|
} from '@/app/reader/utils/annotatorUtil';
|
|
import { Point } from '@/utils/sel';
|
|
import { BookNote, HighlightColor, HighlightStyle, UserHighlightColor } from '@/types/book';
|
|
import { SystemSettings } from '@/types/settings';
|
|
import { FoliateView, NOTE_PREFIX } from '@/types/view';
|
|
|
|
describe('getExternalDragHandle', () => {
|
|
const currentStart: Point = { x: 100, y: 200 };
|
|
const currentEnd: Point = { x: 300, y: 200 };
|
|
|
|
it('forward drag — externalDragPoint closer to end → returns end', () => {
|
|
const result = getExternalDragHandle(currentStart, currentEnd, { x: 280, y: 200 });
|
|
expect(result).toBe('end');
|
|
});
|
|
|
|
it('backward drag — externalDragPoint closer to start → returns start', () => {
|
|
const result = getExternalDragHandle(currentStart, currentEnd, { x: 120, y: 200 });
|
|
expect(result).toBe('start');
|
|
});
|
|
|
|
it('returns null when externalDragPoint is null', () => {
|
|
const result = getExternalDragHandle(currentStart, currentEnd, null);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null when externalDragPoint is undefined', () => {
|
|
const result = getExternalDragHandle(currentStart, currentEnd);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('vertical text — works with vertical coordinates', () => {
|
|
const vStart: Point = { x: 200, y: 100 };
|
|
const vEnd: Point = { x: 200, y: 400 };
|
|
const result = getExternalDragHandle(vStart, vEnd, { x: 200, y: 350 });
|
|
expect(result).toBe('end');
|
|
});
|
|
|
|
it('equal distance — returns end (deterministic tie-breaking)', () => {
|
|
// Midpoint between start(100,200) and end(300,200) is (200,200)
|
|
// distToStart === distToEnd, so !(distToStart < distToEnd) → returns 'end'
|
|
const result = getExternalDragHandle(currentStart, currentEnd, { x: 200, y: 200 });
|
|
expect(result).toBe('end');
|
|
});
|
|
});
|
|
|
|
describe('toParentViewportPoint', () => {
|
|
it('adds frameRect offset to coordinates', () => {
|
|
const mockFrameElement = {
|
|
getBoundingClientRect: vi.fn(() => ({
|
|
top: 50,
|
|
left: 80,
|
|
right: 0,
|
|
bottom: 0,
|
|
width: 0,
|
|
height: 0,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: vi.fn(),
|
|
})),
|
|
};
|
|
const doc = {
|
|
defaultView: {
|
|
frameElement: mockFrameElement,
|
|
},
|
|
} as unknown as Document;
|
|
|
|
const result = toParentViewportPoint(doc, 100, 200);
|
|
expect(result).toEqual({ x: 180, y: 250 });
|
|
});
|
|
|
|
it('defaults to {0,0} offset when no frameElement (detached doc)', () => {
|
|
const doc = {
|
|
defaultView: null,
|
|
} as unknown as Document;
|
|
|
|
const result = toParentViewportPoint(doc, 100, 200);
|
|
expect(result).toEqual({ x: 100, y: 200 });
|
|
});
|
|
|
|
it('handles non-zero iframe offset (e.g., sidebar shifts iframe right)', () => {
|
|
const mockFrameElement = {
|
|
getBoundingClientRect: vi.fn(() => ({
|
|
top: 0,
|
|
left: 250,
|
|
right: 0,
|
|
bottom: 0,
|
|
width: 0,
|
|
height: 0,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: vi.fn(),
|
|
})),
|
|
};
|
|
const doc = {
|
|
defaultView: {
|
|
frameElement: mockFrameElement,
|
|
},
|
|
} as unknown as Document;
|
|
|
|
const result = toParentViewportPoint(doc, 50, 100);
|
|
expect(result).toEqual({ x: 300, y: 100 });
|
|
});
|
|
});
|
|
|
|
describe('getHighlightColorLabel', () => {
|
|
const makeSettings = (
|
|
userHighlightColors: UserHighlightColor[],
|
|
defaultHighlightLabels: Partial<Record<string, string>> = {},
|
|
): SystemSettings =>
|
|
({
|
|
globalReadSettings: {
|
|
userHighlightColors,
|
|
defaultHighlightLabels,
|
|
},
|
|
}) as SystemSettings;
|
|
|
|
it('returns the user-set label for a built-in color', () => {
|
|
const settings = makeSettings([], { yellow: 'Foreshadowing' });
|
|
expect(getHighlightColorLabel(settings, 'yellow')).toBe('Foreshadowing');
|
|
});
|
|
|
|
it('returns the user-set label for a hex color, matching case-insensitively', () => {
|
|
const settings = makeSettings([{ hex: '#aabbcc', label: 'Romance' }]);
|
|
expect(getHighlightColorLabel(settings, '#AABBCC')).toBe('Romance');
|
|
});
|
|
|
|
it('returns undefined when the user has not set a label', () => {
|
|
const settings = makeSettings([]);
|
|
expect(getHighlightColorLabel(settings, 'green')).toBeUndefined();
|
|
expect(getHighlightColorLabel(settings, '#123456')).toBeUndefined();
|
|
});
|
|
|
|
it('ignores labels that collapse to whitespace', () => {
|
|
const settings = makeSettings([{ hex: '#abcdef', label: ' ' }], { red: ' ' });
|
|
expect(getHighlightColorLabel(settings, '#abcdef')).toBeUndefined();
|
|
expect(getHighlightColorLabel(settings, 'red')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('removeBookNoteOverlays', () => {
|
|
const makeView = () => {
|
|
const addAnnotation = vi.fn();
|
|
const view = { addAnnotation } as unknown as FoliateView;
|
|
return { view, addAnnotation };
|
|
};
|
|
|
|
const baseNote = (overrides: Partial<BookNote> = {}): BookNote => ({
|
|
id: 'id-1',
|
|
type: 'annotation',
|
|
cfi: 'epubcfi(/6/4!/4/2)',
|
|
note: '',
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
...overrides,
|
|
});
|
|
|
|
it('removes only the highlight overlay for a highlight-only annotation', () => {
|
|
const { view, addAnnotation } = makeView();
|
|
const note = baseNote({ style: 'highlight', color: 'yellow' });
|
|
|
|
removeBookNoteOverlays(view, note);
|
|
|
|
expect(addAnnotation).toHaveBeenCalledTimes(1);
|
|
expect(addAnnotation).toHaveBeenCalledWith(expect.objectContaining({ value: note.cfi }), true);
|
|
const passed = addAnnotation.mock.calls[0]![0] as BookNote & { value: string };
|
|
expect(passed.value.startsWith(NOTE_PREFIX)).toBe(false);
|
|
});
|
|
|
|
it('removes only the note overlay for a note-only annotation', () => {
|
|
const { view, addAnnotation } = makeView();
|
|
const note = baseNote({ note: 'my comment' });
|
|
|
|
removeBookNoteOverlays(view, note);
|
|
|
|
expect(addAnnotation).toHaveBeenCalledTimes(1);
|
|
expect(addAnnotation).toHaveBeenCalledWith(
|
|
expect.objectContaining({ value: `${NOTE_PREFIX}${note.cfi}` }),
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('removes both overlays when the annotation has a highlight and a note', () => {
|
|
const { view, addAnnotation } = makeView();
|
|
const note = baseNote({ style: 'underline', color: 'red', note: 'my comment' });
|
|
|
|
removeBookNoteOverlays(view, note);
|
|
|
|
expect(addAnnotation).toHaveBeenCalledTimes(2);
|
|
const values = addAnnotation.mock.calls.map(
|
|
(call) => (call[0] as BookNote & { value: string }).value,
|
|
);
|
|
expect(values).toContain(note.cfi);
|
|
expect(values).toContain(`${NOTE_PREFIX}${note.cfi}`);
|
|
for (const call of addAnnotation.mock.calls) {
|
|
expect(call[1]).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('does nothing for a bookmark (no highlight, no note text)', () => {
|
|
const { view, addAnnotation } = makeView();
|
|
const bookmark = baseNote({ type: 'bookmark' });
|
|
|
|
removeBookNoteOverlays(view, bookmark);
|
|
|
|
expect(addAnnotation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('treats whitespace-only note text as empty and skips the note overlay', () => {
|
|
const { view, addAnnotation } = makeView();
|
|
const note = baseNote({ style: 'highlight', note: ' \n ' });
|
|
|
|
removeBookNoteOverlays(view, note);
|
|
|
|
expect(addAnnotation).toHaveBeenCalledTimes(1);
|
|
expect(addAnnotation).toHaveBeenCalledWith(expect.objectContaining({ value: note.cfi }), true);
|
|
});
|
|
|
|
it('is a no-op when view is null', () => {
|
|
expect(() => removeBookNoteOverlays(null, baseNote({ style: 'highlight' }))).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('buildTTSSentenceHighlight', () => {
|
|
const params = {
|
|
cfi: 'epubcfi(/6/4!/4/10,/1:0,/1:42)',
|
|
text: 'A spoken sentence.',
|
|
style: 'highlight' as HighlightStyle,
|
|
color: 'yellow' as HighlightColor,
|
|
page: 7,
|
|
};
|
|
|
|
it('builds an annotation BookNote when none exists at the cfi', () => {
|
|
const note = buildTTSSentenceHighlight([], params, 1000);
|
|
expect(note).not.toBeNull();
|
|
expect(note).toMatchObject({
|
|
type: 'annotation',
|
|
cfi: params.cfi,
|
|
text: params.text,
|
|
style: 'highlight',
|
|
color: 'yellow',
|
|
page: 7,
|
|
note: '',
|
|
createdAt: 1000,
|
|
updatedAt: 1000,
|
|
});
|
|
expect(typeof note!.id).toBe('string');
|
|
expect(note!.id.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('returns null (skip) when a live annotation already exists at the cfi', () => {
|
|
const existing: BookNote = {
|
|
id: 'a1',
|
|
type: 'annotation',
|
|
cfi: params.cfi,
|
|
style: 'highlight',
|
|
color: 'red',
|
|
text: params.text,
|
|
note: '',
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
expect(buildTTSSentenceHighlight([existing], params, 1000)).toBeNull();
|
|
});
|
|
|
|
it('builds when the only note at the cfi is soft-deleted', () => {
|
|
const deleted: BookNote = {
|
|
id: 'a1',
|
|
type: 'annotation',
|
|
cfi: params.cfi,
|
|
style: 'highlight',
|
|
color: 'red',
|
|
text: params.text,
|
|
note: '',
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
deletedAt: 5,
|
|
};
|
|
expect(buildTTSSentenceHighlight([deleted], params, 1000)).not.toBeNull();
|
|
});
|
|
|
|
it('builds when the note at the cfi is a non-annotation (bookmark)', () => {
|
|
const bookmark: BookNote = {
|
|
id: 'b1',
|
|
type: 'bookmark',
|
|
cfi: params.cfi,
|
|
note: '',
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
expect(buildTTSSentenceHighlight([bookmark], params, 1000)).not.toBeNull();
|
|
});
|
|
});
|