Skip to content

Commit 57b0e4e

Browse files
authored
Merge pull request #194 from Gnathonic/develop
Release v1.5.4
2 parents 2322b5f + cd82545 commit 57b0e4e

86 files changed

Lines changed: 3183 additions & 563 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/claude-code-review.yml

Lines changed: 0 additions & 56 deletions
This file was deleted.

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## [1.5.4] - 2026-03-19
4+
5+
### Added
6+
7+
- **Continuous scroll reader (Alpha)** - Vertical and horizontal scroll modes with auto orientation matching. Zoom is in development
8+
- **Simplified settings panel** - Volume and Reader settings merged into one section with context-aware visibility
9+
- **Improved archive import support**
10+
11+
### Fixed
12+
13+
- **WebDAV logout when server unreachable** - Can now log out of WebDAV even when the server connection fails (#187)
14+
15+
### Changed
16+
17+
- **Page view mode is now global** - Single/dual/auto page mode is now a device-level setting rather than per-volume
18+
319
## [1.5.3] - 2026-03-13
420

521
### Fixed

e2e/zoom.spec.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* E2E zoom tests with multi-page volume.
5+
* Tests zoom at various positions throughout the volume to catch
6+
* issues that only appear on non-first pages.
7+
*/
8+
9+
test.describe('Multi-page vertical scroll zoom', () => {
10+
test.beforeEach(async ({ page }) => {
11+
// Create a 10-page test volume with distinct colored pages
12+
await page.setContent(`
13+
<style>
14+
* { margin: 0; padding: 0; box-sizing: border-box; }
15+
body { overflow: hidden; background: #000; }
16+
#outer { position: fixed; inset: 0; }
17+
#scroll { width: 100%; height: 100%; overflow: auto; }
18+
.spacer-top, .spacer-bottom { height: 50vh; }
19+
#wrapper { transform-origin: top left; }
20+
.page {
21+
width: 400px; height: 600px; margin: 0 auto;
22+
position: relative; overflow: hidden;
23+
display: flex; align-items: center; justify-content: center;
24+
font-size: 48px; color: white; font-family: monospace;
25+
}
26+
</style>
27+
<div id="outer">
28+
<div id="scroll">
29+
<div id="spacer">
30+
<div class="spacer-top"></div>
31+
<div id="wrapper">
32+
${Array.from(
33+
{ length: 10 },
34+
(_, i) => `
35+
<div class="page" id="page${i}" style="background: hsl(${i * 36}, 70%, 40%)">
36+
Page ${i}
37+
</div>
38+
`
39+
).join('')}
40+
</div>
41+
<div class="spacer-bottom"></div>
42+
</div>
43+
</div>
44+
</div>
45+
<div id="debug" style="position:fixed;bottom:0;left:0;color:white;font-size:11px;z-index:9999;background:rgba(0,0,0,0.8);padding:4px;max-width:100%;white-space:pre-wrap;"></div>
46+
`);
47+
});
48+
49+
/**
50+
* Scroll to a specific page, then zoom at a given screen position.
51+
* Returns the actual vs expected screen position of the content point.
52+
*/
53+
async function zoomAtPage(
54+
page: any,
55+
pageIndex: number,
56+
fromScreenX: number,
57+
fromScreenY: number,
58+
toScreenX: number,
59+
toScreenY: number,
60+
zoom: number
61+
) {
62+
return await page.evaluate(
63+
({ pageIndex, fromScreenX, fromScreenY, toScreenX, toScreenY, zoom }) => {
64+
const scroll = document.getElementById('scroll')!;
65+
const wrapper = document.getElementById('wrapper')!;
66+
const spacer = document.getElementById('spacer')!;
67+
const targetPage = document.getElementById(`page${pageIndex}`)!;
68+
const vh = window.innerHeight;
69+
const vw = window.innerWidth;
70+
71+
// First, scroll to the target page (center it vertically)
72+
targetPage.scrollIntoView({ block: 'center' });
73+
74+
// Read wrapper rect AFTER scroll
75+
const wrapperRect = wrapper.getBoundingClientRect();
76+
const currentZoom = 1;
77+
78+
// Convert screen position to content space
79+
const contentX = (fromScreenX - wrapperRect.left) / currentZoom;
80+
const contentY = (fromScreenY - wrapperRect.top) / currentZoom;
81+
82+
// Set spacer dimensions
83+
spacer.style.width = `${wrapper.offsetWidth * zoom}px`;
84+
spacer.style.minHeight = `${wrapper.offsetHeight * zoom + vh}px`;
85+
86+
// Apply transform
87+
wrapper.style.transform = `scale(${zoom})`;
88+
89+
// Force layout
90+
void scroll.scrollWidth;
91+
92+
// Compute scroll position
93+
const WRAPPER_OFFSET_X = 0;
94+
const WRAPPER_OFFSET_Y = vh / 2;
95+
const computedScrollLeft = WRAPPER_OFFSET_X + contentX * zoom - toScreenX;
96+
const computedScrollTop = WRAPPER_OFFSET_Y + contentY * zoom - toScreenY;
97+
98+
scroll.scrollLeft = computedScrollLeft;
99+
scroll.scrollTop = computedScrollTop;
100+
101+
// Verify: where did the content point actually end up?
102+
const newWrapperRect = wrapper.getBoundingClientRect();
103+
const actualScreenX = newWrapperRect.left + contentX * zoom;
104+
const actualScreenY = newWrapperRect.top + contentY * zoom;
105+
106+
// Also check what the page element looks like
107+
const pageRect = targetPage.getBoundingClientRect();
108+
109+
const debug = document.getElementById('debug')!;
110+
debug.textContent = [
111+
`Page ${pageIndex} zoom=${zoom}`,
112+
`content=(${contentX.toFixed(0)}, ${contentY.toFixed(0)})`,
113+
`target=(${toScreenX}, ${toScreenY})`,
114+
`actual=(${actualScreenX.toFixed(0)}, ${actualScreenY.toFixed(0)})`,
115+
`scroll: computed=(${computedScrollLeft.toFixed(0)}, ${computedScrollTop.toFixed(0)}) actual=(${scroll.scrollLeft}, ${scroll.scrollTop})`,
116+
`scrollW=${scroll.scrollWidth} scrollH=${scroll.scrollHeight}`,
117+
`wrapperRect=(${newWrapperRect.left.toFixed(0)}, ${newWrapperRect.top.toFixed(0)})`,
118+
`pageRect=(${pageRect.left.toFixed(0)}, ${pageRect.top.toFixed(0)}, ${pageRect.width.toFixed(0)}x${pageRect.height.toFixed(0)})`,
119+
`scrollClamped: L=${computedScrollLeft !== scroll.scrollLeft} T=${computedScrollTop !== scroll.scrollTop}`
120+
].join('\n');
121+
122+
return {
123+
pageIndex,
124+
contentX,
125+
contentY,
126+
computedScrollLeft,
127+
computedScrollTop,
128+
actualScrollLeft: scroll.scrollLeft,
129+
actualScrollTop: scroll.scrollTop,
130+
actualScreenX,
131+
actualScreenY,
132+
targetScreenX: toScreenX,
133+
targetScreenY: toScreenY,
134+
scrollWidth: scroll.scrollWidth,
135+
scrollHeight: scroll.scrollHeight,
136+
errorX: Math.abs(actualScreenX - toScreenX),
137+
errorY: Math.abs(actualScreenY - toScreenY),
138+
scrollClampedLeft: computedScrollLeft !== scroll.scrollLeft,
139+
scrollClampedTop: computedScrollTop !== scroll.scrollTop
140+
};
141+
},
142+
{ pageIndex, fromScreenX, fromScreenY, toScreenX, toScreenY, zoom }
143+
);
144+
}
145+
146+
// Test zoom at page 0 (first page — known to work)
147+
test('page 0: double-tap center zooms correctly', async ({ page }) => {
148+
const vp = page.viewportSize()!;
149+
const r = await zoomAtPage(
150+
page,
151+
0,
152+
vp.width / 2,
153+
vp.height / 2,
154+
vp.width / 2,
155+
vp.height / 2,
156+
2
157+
);
158+
console.log(
159+
`Page 0 center: error=(${r.errorX.toFixed(1)}, ${r.errorY.toFixed(1)}) clamped=(${r.scrollClampedLeft}, ${r.scrollClampedTop})`
160+
);
161+
expect(r.errorX).toBeLessThan(2);
162+
expect(r.errorY).toBeLessThan(2);
163+
});
164+
165+
// Test zoom at page 4 (middle of volume — likely to fail)
166+
test('page 4: double-tap center zooms correctly', async ({ page }) => {
167+
const vp = page.viewportSize()!;
168+
const r = await zoomAtPage(
169+
page,
170+
4,
171+
vp.width / 2,
172+
vp.height / 2,
173+
vp.width / 2,
174+
vp.height / 2,
175+
2
176+
);
177+
console.log(
178+
`Page 4 center: error=(${r.errorX.toFixed(1)}, ${r.errorY.toFixed(1)}) clamped=(${r.scrollClampedLeft}, ${r.scrollClampedTop})`
179+
);
180+
expect(r.errorX).toBeLessThan(2);
181+
expect(r.errorY).toBeLessThan(2);
182+
});
183+
184+
// Test zoom at page 9 (last page)
185+
test('page 9: double-tap center zooms correctly', async ({ page }) => {
186+
const vp = page.viewportSize()!;
187+
const r = await zoomAtPage(
188+
page,
189+
9,
190+
vp.width / 2,
191+
vp.height / 2,
192+
vp.width / 2,
193+
vp.height / 2,
194+
2
195+
);
196+
console.log(
197+
`Page 9 center: error=(${r.errorX.toFixed(1)}, ${r.errorY.toFixed(1)}) clamped=(${r.scrollClampedLeft}, ${r.scrollClampedTop})`
198+
);
199+
expect(r.errorX).toBeLessThan(2);
200+
expect(r.errorY).toBeLessThan(2);
201+
});
202+
203+
// Test zoom at page 4 — click on RIGHT side, target CENTER
204+
test('page 4: right-side click zooms to center', async ({ page }) => {
205+
const vp = page.viewportSize()!;
206+
const r = await zoomAtPage(
207+
page,
208+
4,
209+
vp.width / 2 + 150,
210+
vp.height / 2,
211+
vp.width / 2,
212+
vp.height / 2,
213+
2
214+
);
215+
console.log(
216+
`Page 4 right: error=(${r.errorX.toFixed(1)}, ${r.errorY.toFixed(1)}) clamped=(${r.scrollClampedLeft}, ${r.scrollClampedTop})`
217+
);
218+
expect(r.errorX).toBeLessThan(2);
219+
expect(r.errorY).toBeLessThan(2);
220+
});
221+
222+
// Test zoom at page 4 — click on LEFT side, target CENTER
223+
test('page 4: left-side click zooms to center', async ({ page }) => {
224+
const vp = page.viewportSize()!;
225+
const r = await zoomAtPage(
226+
page,
227+
4,
228+
vp.width / 2 - 150,
229+
vp.height / 2,
230+
vp.width / 2,
231+
vp.height / 2,
232+
2
233+
);
234+
console.log(
235+
`Page 4 left: error=(${r.errorX.toFixed(1)}, ${r.errorY.toFixed(1)}) clamped=(${r.scrollClampedLeft}, ${r.scrollClampedTop})`
236+
);
237+
// Left side might get clamped if scrollLeft would be negative
238+
if (!r.scrollClampedLeft) {
239+
expect(r.errorX).toBeLessThan(2);
240+
}
241+
expect(r.errorY).toBeLessThan(2);
242+
});
243+
244+
// Test zoom at page 7 with zoom level 3
245+
test('page 7: zoom 3x center', async ({ page }) => {
246+
const vp = page.viewportSize()!;
247+
const r = await zoomAtPage(
248+
page,
249+
7,
250+
vp.width / 2,
251+
vp.height / 2,
252+
vp.width / 2,
253+
vp.height / 2,
254+
3
255+
);
256+
console.log(
257+
`Page 7 z3: error=(${r.errorX.toFixed(1)}, ${r.errorY.toFixed(1)}) clamped=(${r.scrollClampedLeft}, ${r.scrollClampedTop})`
258+
);
259+
expect(r.errorX).toBeLessThan(2);
260+
expect(r.errorY).toBeLessThan(2);
261+
});
262+
});

0 commit comments

Comments
 (0)