feat: visionneuse d'images - navigation fluide, image ajustee au cadre, fleches laterales et pellicule persistante #111
This commit is contained in:
@@ -123,4 +123,40 @@ test.describe('Image viewer — zoom / pan / navigation (#108)', () => {
|
||||
await expect(metaPanel).toBeVisible();
|
||||
await expect(title).not.toHaveText(titleBefore);
|
||||
});
|
||||
|
||||
test('navigation en place : flèches latérales, pellicule persistante et cadre ajusté (#111)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-image.png');
|
||||
|
||||
const container = page.locator('#content-area .image-viewer-container');
|
||||
const strip = page.locator('#content-area .image-nav-strip');
|
||||
const main = page.locator('#content-area img.image-main');
|
||||
await expect(strip).toBeVisible({ timeout: 10000 });
|
||||
await expect(strip.locator('img.image-thumb')).toHaveCount(2);
|
||||
|
||||
// Marqueur : la navigation en place ne doit PAS recréer le conteneur.
|
||||
await container.evaluate((el) => { el.dataset.inplace = '1'; });
|
||||
|
||||
// L'image reste contenue dans le cadre (jamais plus grande).
|
||||
const stageBox = await page.locator('#content-area .image-stage').boundingBox();
|
||||
const imgBox = await main.boundingBox();
|
||||
expect(imgBox.width).toBeLessThanOrEqual(stageBox.width + 1);
|
||||
expect(imgBox.height).toBeLessThanOrEqual(stageBox.height + 1);
|
||||
|
||||
// Flèche superposée révélée au survol.
|
||||
const nextArrow = page.locator('#content-area .image-nav-arrow-next');
|
||||
await expect(nextArrow).toBeAttached();
|
||||
await nextArrow.hover();
|
||||
await expect.poll(() => nextArrow.evaluate((el) => getComputedStyle(el).opacity)).toBe('1');
|
||||
|
||||
const title = page.locator('#content-area .image-title');
|
||||
const before = await title.innerText();
|
||||
await nextArrow.click();
|
||||
|
||||
await expect(title).not.toHaveText(before);
|
||||
await expect(strip).toBeVisible();
|
||||
await expect(strip.locator('img.image-thumb')).toHaveCount(2);
|
||||
await expect(container).toHaveAttribute('data-inplace', '1');
|
||||
await expect(page.locator('#content-area .image-counter')).toContainText('/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,36 +77,51 @@ test("renderImageViewer never uses the JSON raw endpoint as an image src", () =>
|
||||
assert.ok(start !== -1 && stop > start, "renderImageViewer block not found");
|
||||
const block = viewer.slice(start, stop);
|
||||
assert.doesNotMatch(block, /\/raw\?path=/, "images must be served by /api/image, not /raw");
|
||||
assert.match(block, /buildImageUrl\(vault, path\)/);
|
||||
assert.match(block, /buildImageUrl\(vault, currentPath\)/);
|
||||
});
|
||||
|
||||
test("image viewer supports wheel zoom, drag pan and double-click reset", () => {
|
||||
assert.match(viewer, /stage\.addEventListener\("wheel"/);
|
||||
assert.match(viewer, /stage\.addEventListener\("pointerdown"/);
|
||||
assert.match(viewer, /stage\.addEventListener\("dblclick", resetView\)/);
|
||||
assert.match(viewer, /stage\.addEventListener\("dblclick", \(e\) => \{ if \(!isArrow\(e\)\) resetView\(\); \}\)/);
|
||||
assert.match(viewer, /renderImageViewer/);
|
||||
});
|
||||
|
||||
test("image viewer exposes thumbnail navigation via /api/media thumb", () => {
|
||||
assert.match(viewer, /\/api\/media\/\$\{encodeURIComponent\(vault\)\}\/thumb\?path=/);
|
||||
assert.match(viewer, /let siblings = \[\]/);
|
||||
assert.match(viewer, /siblings = \(res\.items \|\| \[\]\)/);
|
||||
// #111 — the sibling list comes from the memoised directory listing.
|
||||
assert.match(viewer, /listImageSiblings/);
|
||||
});
|
||||
|
||||
// BUG-072 — lightbox + metadata panel must survive ←/→ (and filmstrip) navigation.
|
||||
// BUG-072 — lightbox + metadata panel keep their state; #111 — navigation is
|
||||
// in place, so the viewer (and its filmstrip) is no longer rebuilt.
|
||||
test("image viewer persists lightbox + metadata state across navigation", () => {
|
||||
assert.match(viewer, /const _imageViewerState = \{ lightbox: false, meta: false \}/);
|
||||
assert.match(viewer, /let _imageViewerNavPending = false/);
|
||||
// Both navigation paths flag the next render as an image→image navigation.
|
||||
assert.match(viewer, /_imageViewerNavPending = true;\s*\n\s*openFile\(vault, siblings\[next\]\.path\)/);
|
||||
assert.match(viewer, /_imageViewerNavPending = true;\s*\n\s*openFile\(vault, s\.path\)/);
|
||||
// renderFile resets the state only when the next render is not a carried-over image.
|
||||
assert.match(viewer, /if \(!data\.is_image \|\| !_imageViewerNavPending\)/);
|
||||
// renderFile only resets the viewer state when the next render is not an image.
|
||||
assert.match(viewer, /if \(!data\.is_image\) \{\s*\n\s*_imageViewerState\.lightbox = false;/);
|
||||
// The persisted state drives the container class and the panel visibility.
|
||||
assert.match(viewer, /classList\.toggle\("lightbox", _imageViewerState\.lightbox\)/);
|
||||
assert.match(viewer, /metaPanel\.hidden = !_imageViewerState\.meta/);
|
||||
});
|
||||
|
||||
// #111 — switching siblings swaps the <img> in place instead of re-rendering
|
||||
// (no openFile round-trip, filmstrip stays in the DOM, neighbours preloaded).
|
||||
test("image viewer navigates between siblings in place (#111)", () => {
|
||||
assert.doesNotMatch(viewer, /_imageViewerNavPending/);
|
||||
assert.match(viewer, /const showSibling = \(index\) =>/);
|
||||
assert.match(viewer, /const go = \(delta\) => \{\s*\n\s*if \(siblings\.length < 2[^)]*\) return;\s*\n\s*showSibling\(/);
|
||||
// Thumbnail clicks and the overlay arrows both call showSibling/go in place.
|
||||
assert.match(viewer, /thumb\.addEventListener\("click", \(\) => showSibling\(i\)\)/);
|
||||
assert.match(viewer, /class: "image-nav-arrow image-nav-arrow-prev"/);
|
||||
assert.match(viewer, /class: "image-nav-arrow image-nav-arrow-next"/);
|
||||
// The active thumbnail is only re-highlighted / scrolled, never rebuilt.
|
||||
assert.match(viewer, /updateStripActive/);
|
||||
assert.match(viewer, /const preload = \(i\) =>/);
|
||||
// Directory listing is cached for a short TTL.
|
||||
assert.match(viewer, /const _imageDirCache = new Map\(\)/);
|
||||
});
|
||||
|
||||
// ── Static checks: CSS ─────────────────────────────────────────────────────
|
||||
test("style.css defines the image viewer layout + lightbox", () => {
|
||||
assert.match(css, /\.image-stage\s*\{/);
|
||||
@@ -115,6 +130,27 @@ test("style.css defines the image viewer layout + lightbox", () => {
|
||||
assert.match(css, /\.image-viewer-container\.lightbox\s*\{/);
|
||||
});
|
||||
|
||||
// #111 — the viewer fills the content box and the image is capped to the frame.
|
||||
test("style.css resizes the image to the available frame (#111)", () => {
|
||||
assert.match(css, /\.content-area:has\(> \.image-viewer-container\)\s*\{[^}]*padding:\s*0/);
|
||||
assert.match(css, /\.content-area:has\(> \.image-viewer-container\)\s*\{[^}]*overflow:\s*hidden/);
|
||||
const main = /\.image-main\s*\{([^}]*)\}/.exec(css);
|
||||
assert.ok(main, ".image-main rule not found");
|
||||
assert.match(main[1], /max-width:\s*100%/);
|
||||
assert.match(main[1], /max-height:\s*100%/);
|
||||
});
|
||||
|
||||
// #111 — translucent overlay arrows on both frame edges, revealed on hover.
|
||||
test("style.css styles the overlay navigation arrows (#111)", () => {
|
||||
assert.match(css, /\.image-nav-arrow\s*\{/);
|
||||
assert.match(css, /\.image-nav-arrow-prev\s*\{\s*left:\s*0/);
|
||||
assert.match(css, /\.image-nav-arrow-next\s*\{\s*right:\s*0/);
|
||||
const arrow = /\.image-nav-arrow\s*\{([^}]*)\}/.exec(css);
|
||||
assert.ok(arrow, ".image-nav-arrow rule not found");
|
||||
assert.match(arrow[1], /opacity:\s*0/);
|
||||
assert.match(css, /\.image-nav-arrow:hover[\s\S]*?opacity:\s*1/);
|
||||
});
|
||||
|
||||
// BUG-072 — metadata is a right-hand sidebar, not a strip under the filmstrip,
|
||||
// and it stays visible in lightbox mode.
|
||||
test("style.css lays the metadata panel out as a right sidebar", () => {
|
||||
|
||||
Reference in New Issue
Block a user