forked from akai/readest
789d031222
Snap the reading ruler to real rendered text lines instead of stepping by a fixed arithmetic height, so the band always frames whole lines. - Snap to actual line geometry from the relocate range; the band is sized dynamically to the text block plus symmetric padding (round(fontSize * lineHeight * 0.3)), capped at (lines + 1) line heights so a tall image inside a block can't expand it to cover the whole figure. - Drop block/container rects (Range.getClientRects aggregates multi-line element borders) so paragraphs aren't merged into one giant line and skipped. - Column-aware in multi-column layouts: the band spans one column at a time and advances column by column. - Confine the band to lines at least half visible within the viewport. - Scrolled mode: snap to lines, and at a view edge scroll the view and realign the band to the start/end of the new view (works for vertical-rl too, which scrolls horizontally); paging snaps the view edge between lines so text isn't cut or repeated. - Vertical writing mode: correct band centering and drag direction; Up/Down keys move the ruler while Left/Right turn pages (taps always move the ruler). - Page turns keep the first/last line: forward lands on the first line of the new page, backward on the last line; the relayout re-snap anchors on the band's leading edge so it never skips a line. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
buildLineBoxes,
|
|
buildReadingRulerColumns,
|
|
calculateReadingRulerPadding,
|
|
calculateReadingRulerSize,
|
|
clampReadingRulerPosition,
|
|
FIXED_LAYOUT_READING_RULER_LINE_HEIGHT,
|
|
filterVisibleLineBoxes,
|
|
getReadingRulerMoveDirection,
|
|
isReadingRulerMoveKey,
|
|
snapReadingRulerColumns,
|
|
snapReadingRulerToLines,
|
|
stepReadingRulerPosition,
|
|
} from '@/app/reader/utils/readingRuler';
|
|
|
|
describe('readingRuler utils', () => {
|
|
it('calculates fixed-layout ruler size from the configured line count', () => {
|
|
expect(
|
|
calculateReadingRulerSize(
|
|
3,
|
|
{
|
|
defaultFontSize: 16,
|
|
lineHeight: 1.5,
|
|
},
|
|
'PDF',
|
|
),
|
|
).toBe(3 * FIXED_LAYOUT_READING_RULER_LINE_HEIGHT);
|
|
});
|
|
|
|
it('calculates reflowable ruler size from font size and line height', () => {
|
|
expect(
|
|
calculateReadingRulerSize(
|
|
2,
|
|
{
|
|
defaultFontSize: 18,
|
|
lineHeight: 1.4,
|
|
},
|
|
'EPUB',
|
|
),
|
|
).toBe(50);
|
|
});
|
|
|
|
it('clamps the ruler center so the full ruler stays inside the viewport', () => {
|
|
expect(clampReadingRulerPosition(5, 1000, 200)).toBe(10);
|
|
expect(clampReadingRulerPosition(95, 1000, 200)).toBe(90);
|
|
expect(clampReadingRulerPosition(33, 1000, 200)).toBe(33);
|
|
});
|
|
|
|
it('centers the ruler when it is larger than the viewport', () => {
|
|
expect(clampReadingRulerPosition(10, 100, 120)).toBe(50);
|
|
expect(clampReadingRulerPosition(90, 100, 120)).toBe(50);
|
|
});
|
|
|
|
it('moves by exactly one ruler window per navigation step', () => {
|
|
expect(stepReadingRulerPosition(33, 1000, 200, 'forward')).toBe(53);
|
|
expect(stepReadingRulerPosition(33, 1000, 200, 'backward')).toBe(13);
|
|
});
|
|
|
|
it('clamps stepped movement at the edges', () => {
|
|
expect(stepReadingRulerPosition(85, 1000, 200, 'forward')).toBe(90);
|
|
expect(stepReadingRulerPosition(15, 1000, 200, 'backward')).toBe(10);
|
|
});
|
|
|
|
it('maps tap and key sides to logical ruler movement direction', () => {
|
|
expect(getReadingRulerMoveDirection('right', 'ltr')).toBe('forward');
|
|
expect(getReadingRulerMoveDirection('left', 'ltr')).toBe('backward');
|
|
expect(getReadingRulerMoveDirection('right', 'rtl')).toBe('backward');
|
|
expect(getReadingRulerMoveDirection('left', 'rtl')).toBe('forward');
|
|
expect(getReadingRulerMoveDirection('down', 'rtl')).toBe('forward');
|
|
expect(getReadingRulerMoveDirection('up', 'ltr')).toBe('backward');
|
|
});
|
|
|
|
it('restricts arrow-key ruler movement to up/down in vertical layout', () => {
|
|
// Vertical: only up/down move the ruler; left/right turn pages.
|
|
expect(isReadingRulerMoveKey('up', true)).toBe(true);
|
|
expect(isReadingRulerMoveKey('down', true)).toBe(true);
|
|
expect(isReadingRulerMoveKey('left', true)).toBe(false);
|
|
expect(isReadingRulerMoveKey('right', true)).toBe(false);
|
|
// Horizontal: every arrow moves the ruler.
|
|
expect(isReadingRulerMoveKey('up', false)).toBe(true);
|
|
expect(isReadingRulerMoveKey('left', false)).toBe(true);
|
|
expect(isReadingRulerMoveKey('right', false)).toBe(true);
|
|
});
|
|
|
|
it('pads each side by 0.3 of a line height', () => {
|
|
// round(16 * 1.5 * 0.3) = 7
|
|
expect(calculateReadingRulerPadding({ defaultFontSize: 16, lineHeight: 1.5 }, 'EPUB')).toBe(7);
|
|
// fixed-layout: round(28 * 0.3) = 8
|
|
expect(calculateReadingRulerPadding({ defaultFontSize: 16, lineHeight: 1.5 }, 'PDF')).toBe(
|
|
Math.round(FIXED_LAYOUT_READING_RULER_LINE_HEIGHT * 0.3),
|
|
);
|
|
});
|
|
});
|
|
|
|
type RectLike = {
|
|
top: number;
|
|
bottom: number;
|
|
left: number;
|
|
right: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
const rect = (top: number, left: number, height: number, width: number): RectLike => ({
|
|
top,
|
|
left,
|
|
bottom: top + height,
|
|
right: left + width,
|
|
height,
|
|
width,
|
|
});
|
|
|
|
const container = { top: 0, left: 0, right: 300, bottom: 400 };
|
|
|
|
describe('buildLineBoxes', () => {
|
|
it('clusters horizontal fragments into one box per visual line', () => {
|
|
const rects = [
|
|
rect(0, 10, 16, 50), // line 1, fragment A
|
|
rect(0, 60, 16, 40), // line 1, fragment B (same vertical band)
|
|
rect(20, 10, 16, 80), // line 2
|
|
];
|
|
expect(buildLineBoxes(rects, false, false, container)).toEqual([
|
|
{ start: 0, end: 16 },
|
|
{ start: 20, end: 36 },
|
|
]);
|
|
});
|
|
|
|
it('ignores zero-size rects', () => {
|
|
const rects = [rect(0, 10, 16, 50), rect(20, 0, 0, 0), rect(40, 10, 16, 50)];
|
|
expect(buildLineBoxes(rects, false, false, container)).toEqual([
|
|
{ start: 0, end: 16 },
|
|
{ start: 40, end: 56 },
|
|
]);
|
|
});
|
|
|
|
it('returns sorted boxes even when rects are out of order', () => {
|
|
const rects = [rect(40, 10, 16, 50), rect(0, 10, 16, 50), rect(20, 10, 16, 50)];
|
|
expect(buildLineBoxes(rects, false, false, container).map((b) => b.start)).toEqual([0, 20, 40]);
|
|
});
|
|
|
|
it('maps vertical-rl columns as distance from the right edge', () => {
|
|
// container.right = 300; a column at left=260,right=276 -> [300-276, 300-260] = [24, 40]
|
|
const rects = [rect(0, 260, 200, 16)];
|
|
expect(buildLineBoxes(rects, true, true, container)).toEqual([{ start: 24, end: 40 }]);
|
|
});
|
|
|
|
it('maps vertical-lr columns as distance from the left edge', () => {
|
|
const rects = [rect(0, 24, 200, 16)];
|
|
expect(buildLineBoxes(rects, true, false, container)).toEqual([{ start: 24, end: 40 }]);
|
|
});
|
|
|
|
it('drops multi-line block rects so paragraphs are not merged into one box', () => {
|
|
// Range.getClientRects() includes the <p> border box (h=160) alongside its
|
|
// 4 line boxes; the tall block rect must be discarded, not merged.
|
|
const rects = [
|
|
rect(0, 10, 16, 400),
|
|
rect(20, 10, 16, 400),
|
|
rect(40, 10, 16, 400),
|
|
rect(60, 10, 16, 400),
|
|
rect(0, 10, 160, 400), // the paragraph block box
|
|
];
|
|
expect(buildLineBoxes(rects, false, false, container)).toEqual([
|
|
{ start: 0, end: 16 },
|
|
{ start: 20, end: 36 },
|
|
{ start: 40, end: 56 },
|
|
{ start: 60, end: 76 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('snapReadingRulerToLines', () => {
|
|
// 10 lines, each 16px tall, 20px apart, starting at 0.
|
|
const evenBoxes = [0, 20, 40, 60, 80, 100, 120, 140, 160, 180].map((s) => ({
|
|
start: s,
|
|
end: s + 16,
|
|
}));
|
|
|
|
it('returns null when there are no line boxes', () => {
|
|
expect(snapReadingRulerToLines(0, 36, 2, 'forward', [])).toBeNull();
|
|
});
|
|
|
|
it('returns the next block of N lines as { start, end }', () => {
|
|
// current block covers lines[0..1] => [0,36]; next group is lines[2..3] => [40,76].
|
|
expect(snapReadingRulerToLines(0, 36, 2, 'forward', evenBoxes)).toEqual({ start: 40, end: 76 });
|
|
});
|
|
|
|
it('returns the previous block of N lines when moving backward', () => {
|
|
// current block covers lines[2..3] => [40,76]; previous group is lines[0..1] => [0,36].
|
|
expect(snapReadingRulerToLines(40, 76, 2, 'backward', evenBoxes)).toEqual({
|
|
start: 0,
|
|
end: 36,
|
|
});
|
|
});
|
|
|
|
it('returns the first group from the top with -Infinity', () => {
|
|
expect(snapReadingRulerToLines(-Infinity, -Infinity, 2, 'forward', evenBoxes)).toEqual({
|
|
start: 0,
|
|
end: 36,
|
|
});
|
|
});
|
|
|
|
it('returns the last group from the bottom with Infinity', () => {
|
|
expect(snapReadingRulerToLines(Infinity, Infinity, 2, 'backward', evenBoxes)).toEqual({
|
|
start: 160,
|
|
end: 196,
|
|
});
|
|
});
|
|
|
|
it('returns null at the bottom boundary so the page can flip', () => {
|
|
const boxes = [
|
|
{ start: 0, end: 16 },
|
|
{ start: 20, end: 36 },
|
|
];
|
|
expect(snapReadingRulerToLines(0, 36, 2, 'forward', boxes)).toBeNull();
|
|
});
|
|
|
|
it('returns null at the top boundary so the page can flip', () => {
|
|
const boxes = [
|
|
{ start: 80, end: 96 },
|
|
{ start: 100, end: 116 },
|
|
];
|
|
expect(snapReadingRulerToLines(80, 116, 2, 'backward', boxes)).toBeNull();
|
|
});
|
|
|
|
it('re-snapping forward from a block start stays on the same block (no skip)', () => {
|
|
// Anchoring the re-snap at the band's leading edge must return the same block,
|
|
// not advance one line. (Anchoring at the band CENTER, which sits inside a line,
|
|
// would skip to the next line — the page-turn "first line skipped" bug.)
|
|
const blockStart = 40; // lines[2..3] => [40,76]
|
|
expect(snapReadingRulerToLines(blockStart, blockStart, 2, 'forward', evenBoxes)).toEqual({
|
|
start: 40,
|
|
end: 76,
|
|
});
|
|
// A center anchor (sitting inside lines[2]) advances to the next group — the bug.
|
|
const center = 48; // inside lines[2] (40..56)
|
|
expect(snapReadingRulerToLines(center, center, 2, 'forward', evenBoxes)).toEqual({
|
|
start: 60,
|
|
end: 96,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('filterVisibleLineBoxes', () => {
|
|
it('keeps lines at least half visible within the viewport and drops the rest', () => {
|
|
const boxes = [
|
|
{ start: 0, end: 16 }, // fully visible
|
|
{ start: 190, end: 206 }, // 10/16 visible at the bottom edge -> kept
|
|
{ start: 196, end: 212 }, // 4/16 visible -> dropped
|
|
{ start: -8, end: 8 }, // 8/16 visible at the top edge -> kept
|
|
{ start: -12, end: 4 }, // 4/16 visible -> dropped
|
|
];
|
|
expect(filterVisibleLineBoxes(boxes, 200)).toEqual([
|
|
{ start: 0, end: 16 },
|
|
{ start: 190, end: 206 },
|
|
{ start: -8, end: 8 },
|
|
]);
|
|
});
|
|
|
|
it('returns all line boxes when the dimension is unknown', () => {
|
|
const boxes = [{ start: 500, end: 516 }];
|
|
expect(filterVisibleLineBoxes(boxes, 0)).toEqual(boxes);
|
|
});
|
|
});
|
|
|
|
describe('buildReadingRulerColumns', () => {
|
|
// Two columns in a 1000px-wide overlay: col0 centers < 500, col1 centers >= 500.
|
|
const colRect = (top: number, left: number): RectLike => rect(top, left, 16, 420);
|
|
|
|
it('splits rects into columns by x and lines by y', () => {
|
|
const rects = [colRect(0, 40), colRect(20, 40), colRect(0, 540), colRect(20, 540)];
|
|
const cols = buildReadingRulerColumns(rects, 2, 1000, false);
|
|
expect(cols).toEqual([
|
|
{
|
|
left: 40,
|
|
right: 460,
|
|
lines: [
|
|
{ start: 0, end: 16 },
|
|
{ start: 20, end: 36 },
|
|
],
|
|
},
|
|
{
|
|
left: 540,
|
|
right: 960,
|
|
lines: [
|
|
{ start: 0, end: 16 },
|
|
{ start: 20, end: 36 },
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('orders columns right-to-left when rtl', () => {
|
|
const rects = [colRect(0, 40), colRect(0, 540)];
|
|
const cols = buildReadingRulerColumns(rects, 2, 1000, true);
|
|
expect(cols.map((c) => c.left)).toEqual([540, 40]);
|
|
});
|
|
|
|
it('returns a single full-width column when columnCount is 1', () => {
|
|
const rects = [colRect(0, 40), colRect(0, 540)];
|
|
const cols = buildReadingRulerColumns(rects, 1, 1000, false);
|
|
expect(cols).toHaveLength(1);
|
|
expect(cols[0]!.lines).toHaveLength(1); // both rects share top 0 -> one line
|
|
});
|
|
|
|
it('drops multi-line block rects inside a column', () => {
|
|
const rects = [
|
|
colRect(0, 40),
|
|
colRect(20, 40),
|
|
colRect(40, 40),
|
|
colRect(60, 40),
|
|
rect(0, 40, 160, 420), // paragraph block box in column 0
|
|
];
|
|
const cols = buildReadingRulerColumns(rects, 2, 1000, false);
|
|
expect(cols).toHaveLength(1);
|
|
expect(cols[0]!.lines).toEqual([
|
|
{ start: 0, end: 16 },
|
|
{ start: 20, end: 36 },
|
|
{ start: 40, end: 56 },
|
|
{ start: 60, end: 76 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('snapReadingRulerColumns', () => {
|
|
// col0: 5 lines (0..80); col1: 3 lines (0..40); each 16px tall, 20px apart.
|
|
const columns = [
|
|
{
|
|
left: 40,
|
|
right: 460,
|
|
lines: [0, 20, 40, 60, 80].map((s) => ({ start: s, end: s + 16 })),
|
|
},
|
|
{
|
|
left: 540,
|
|
right: 960,
|
|
lines: [0, 20, 40].map((s) => ({ start: s, end: s + 16 })),
|
|
},
|
|
];
|
|
|
|
it('advances within the active column', () => {
|
|
// col0, current block lines[0..1] => [0,36]; next block lines[2..3] => [40,76].
|
|
expect(snapReadingRulerColumns(0, 0, 36, 2, 'forward', columns)).toEqual({
|
|
columnIndex: 0,
|
|
start: 40,
|
|
end: 76,
|
|
});
|
|
});
|
|
|
|
it('jumps to the next column at the bottom of the current one', () => {
|
|
// col0 current block lines[3..4] => [60,96]; nothing below -> col1 first block [0,36].
|
|
expect(snapReadingRulerColumns(0, 60, 96, 2, 'forward', columns)).toEqual({
|
|
columnIndex: 1,
|
|
start: 0,
|
|
end: 36,
|
|
});
|
|
});
|
|
|
|
it('returns null past the last line of the last column', () => {
|
|
// col1 current block covers its last lines [20,56]; no next group anywhere.
|
|
expect(snapReadingRulerColumns(1, 20, 56, 2, 'forward', columns)).toBeNull();
|
|
});
|
|
|
|
it('jumps to the previous column when moving backward off the top', () => {
|
|
// col1 current block [0,36]; nothing above in col1 -> col0 last block [60,96].
|
|
expect(snapReadingRulerColumns(1, 0, 36, 2, 'backward', columns)).toEqual({
|
|
columnIndex: 0,
|
|
start: 60,
|
|
end: 96,
|
|
});
|
|
});
|
|
|
|
it('returns null when there are no columns', () => {
|
|
expect(snapReadingRulerColumns(0, 0, 36, 2, 'forward', [])).toBeNull();
|
|
});
|
|
});
|