diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c9575b..fa4b9a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented here. ## [unreleased] +## [0.26.3] - Aug 13th, 2026 +- **Fix wheel-zoom and drag-zoom focal point when the ULabel container is offset from the viewport origin.** `handle_wheel` and `drag_rezoom` used to pass raw `clientX`/`clientY` (viewport coords) straight into `rezoom`, which treats its focal-point arguments as annbox-local. When the container sat at viewport `(0, 0)` the two frames coincided and the bug was invisible; anywhere else (e.g. hosted inside a centered dialog, a padded panel, or below a header) zoom would snap by roughly the annbox's screen offset. Both call sites now convert to annbox-local via `getBoundingClientRect()` before calling `rezoom`, so zoom stays anchored to the cursor / mousedown point regardless of how the host embeds ULabel. +- **New config option `min_zoom_fit_ratio`.** Sets a zoom-out floor as a multiplier of the "whole image just fits the viewport" zoom. `0` (default) preserves the existing behavior (no floor). `1.0` prevents users from zooming out past the fit-to-viewport level. `>1` forces the image to always overflow. Zoom-in is unaffected. The floor recomputes from live annbox dimensions, so it adapts to browser resize. + ## [0.26.2] - Aug 13th, 2026 - **Fix regression in `set_annotations()` where hover feedback and the brush stopped working after a swap.** The 0.26.1 bulk-teardown path called `$("#canvasses__").empty()`, which removed not just the per-annotation canvases but also the subtask's front canvas and the `#dialogs__` container (which owns the brush circle and polygon ender). `state.front_context` was left pointing at a detached canvas so hover highlights painted into nothing, and the brush had no parent to attach to. The teardown now removes only `> canvas.annotation_canvas` children, leaving the front/back canvases and dialogs container intact. - New demo: [`demo/set-annotations.html`](demo/set-annotations.html) with buttons that swap through empty / small / medium / large / RLE-bitmask / raw-Uint8Array-bitmask presets so this regression stays visible. diff --git a/api_spec.md b/api_spec.md index 2a4422bf..fc2bb064 100644 --- a/api_spec.md +++ b/api_spec.md @@ -81,6 +81,7 @@ class ULabel({ annotation_size_minus_keybind: string, annotation_vanish_keybind: string, fly_to_max_zoom: number, + min_zoom_fit_ratio: number, n_annos_per_canvas: number, auto_destroy_on_detach: boolean }) @@ -624,6 +625,9 @@ Keybind to toggle vanish mode for all subtasks. Default is `shift+v` ### `fly_to_max_zoom` Maximum zoom factor used when flying-to an annotation. Default is `10`, value must be > `0`. +### `min_zoom_fit_ratio` +Zoom-out floor, expressed as a multiplier of the "whole image just fits the viewport" zoom (the same level reached by the `shift+r` keybind / the toolbox "show whole image" button). Default is `0`, which disables the floor. `1.0` prevents users from zooming out past the fit-to-viewport level. Values `> 1` force the image to always overflow the viewport by that factor. Zoom-in is unaffected. The floor recomputes from live annbox dimensions on every zoom, so it adapts to browser resize. + ### `n_annos_per_canvas` The number of annotations to render on a single canvas. Default is `100`. Increasing this number may improve performance for jobs with a large number of annotations. diff --git a/demo.js b/demo.js index 66a3c5b6..34b5a6ca 100644 --- a/demo.js +++ b/demo.js @@ -15,4 +15,5 @@ console.log(`http://localhost:${port}/resume-from.html`); console.log(`http://localhost:${port}/row-filtering-example.html`); console.log(`http://localhost:${port}/bitmask-example.html`); console.log(`http://localhost:${port}/set-annotations.html`); -console.log(`http://localhost:${port}/live_demo.html`); \ No newline at end of file +console.log(`http://localhost:${port}/live_demo.html`); +console.log(`http://localhost:${port}/offset-container.html`); \ No newline at end of file diff --git a/demo/offset-container.html b/demo/offset-container.html new file mode 100644 index 00000000..3575f9e2 --- /dev/null +++ b/demo/offset-container.html @@ -0,0 +1,95 @@ + + + + ULabel (offset container) + + + + + + + + + + + + + +
+
+
+ + diff --git a/demo/set-annotations.html b/demo/set-annotations.html index 5945de41..3fedefda 100644 --- a/demo/set-annotations.html +++ b/demo/set-annotations.html @@ -141,6 +141,7 @@ submit_buttons: [{ name: "Submit", hook: on_submit }], subtasks: subtasks, initial_line_size: 2, + min_zoom_fit_ratio: 1.0 }); window.ulabel = ulabel; diff --git a/index.d.ts b/index.d.ts index be166584..ec370d04 100644 --- a/index.d.ts +++ b/index.d.ts @@ -586,6 +586,11 @@ export class ULabel { foc_y?: number, abs?: boolean, ): void; + public set_zoom_val(zoom_val: number): void; + public viewport_to_annbox_local( + client_x: number, + client_y: number, + ): { x: number; y: number }; public reposition_dialogs(): void; public handle_toolbox_overflow(): void; diff --git a/package-lock.json b/package-lock.json index 563c4573..e2f0bc85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ulabel", - "version": "0.26.2", + "version": "0.26.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ulabel", - "version": "0.26.2", + "version": "0.26.3", "license": "MIT", "devDependencies": { "@eslint/config-inspector": "^1.3.0", diff --git a/package.json b/package.json index dd4fa5bd..a2f73ee7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ulabel", "description": "An image annotation tool.", - "version": "0.26.2", + "version": "0.26.3", "main": "dist/ulabel.min.js", "module": "dist/ulabel.min.js", "types": "dist/index.d.ts", diff --git a/src/configuration.ts b/src/configuration.ts index 8e95aec3..066ad150 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -270,6 +270,8 @@ export class Configuration { public fly_to_max_zoom: number = 10; + public min_zoom_fit_ratio: number = 0; + public n_annos_per_canvas: number = DEFAULT_N_ANNOS_PER_CANVAS; public click_and_drag_poly_annotations: boolean = true; diff --git a/src/index.js b/src/index.js index 10aee928..e83d4803 100644 --- a/src/index.js +++ b/src/index.js @@ -6642,14 +6642,30 @@ export class ULabel { const dlta = Math.sign(wheel_event.deltaY); // Apply new zoom - this.state["zoom_val"] *= (1 - dlta / 5); - this.rezoom(wheel_event.clientX, wheel_event.clientY); + this.set_zoom_val(this.state["zoom_val"] * (1 - dlta / 5)); + const foc = this.viewport_to_annbox_local(wheel_event.clientX, wheel_event.clientY); + this.rezoom(foc.x, foc.y); // Only try to update the overlay if it exists this.filter_distance_overlay?.draw_overlay(); } } + // Convert a viewport-space point (e.g. `event.clientX`/`clientY`) into the annbox's + // unscaled layout coordinate system, which is what `rezoom` and `annbox.scrollLeft/Top` + // operate in. `getBoundingClientRect()` returns transformed dimensions, so we divide out + // any ancestor CSS scale by comparing rendered vs. layout size. + viewport_to_annbox_local(client_x, client_y) { + const annbox = document.getElementById(this.config["annbox_id"]); + const rect = annbox.getBoundingClientRect(); + const scale_x = annbox.clientWidth > 0 ? rect.width / annbox.clientWidth : 1; + const scale_y = annbox.clientHeight > 0 ? rect.height / annbox.clientHeight : 1; + return { + x: (client_x - rect.left) / (scale_x || 1), + y: (client_y - rect.top) / (scale_y || 1), + }; + } + // Start dragging to pan around image // Called when mousedown fires within annbox start_drag(drag_key, release_button, mouse_event) { @@ -6782,13 +6798,54 @@ export class ULabel { 1.1, -(aY - this.drag_state["zoom"]["mouse_start"][1]) / 10, ), ); - this.rezoom(this.drag_state["zoom"]["mouse_start"][0], this.drag_state["zoom"]["mouse_start"][1]); + const foc = this.viewport_to_annbox_local( + this.drag_state["zoom"]["mouse_start"][0], + this.drag_state["zoom"]["mouse_start"][1], + ); + + // Compute scroll from the drag-start baseline + const annbox = $("#" + this.config["annbox_id"]); + + const new_width = Math.round(this.config["image_width"] * this.state["zoom_val"]); + const new_height = Math.round(this.config["image_height"] * this.state["zoom_val"]); + + // Resize + var toresize = $("." + this.config["imgsz_class"]); + toresize.css("width", new_width + "px"); + toresize.css("height", new_height + "px"); + this.filter_distance_overlay?.resize_canvas(new_width, new_height); + this.resize_active_polygon_ender(); + + // Scroll from drag-start baseline + const start_width = this.config["image_width"] * this.drag_state["zoom"]["zoom_val_start"]; + const start_height = this.config["image_height"] * this.drag_state["zoom"]["zoom_val_start"]; + const old_left = this.drag_state["zoom"]["offset_start"][0]; + const old_top = this.drag_state["zoom"]["offset_start"][1]; + annbox.scrollLeft((old_left + foc.x) * new_width / start_width - foc.x); + annbox.scrollTop((old_top + foc.y) * new_height / start_height - foc.y); + + this.redraw_demo(); + if (this.state.anno_scaling_mode === "inverse-zoom" || this.state.anno_scaling_mode === "match-zoom") { + this.redraw_all_annotations(); + } } // Set the zoom value in state and render accordingly set_zoom_val(zoom_val) { - // Prevent zoom val <= 0 - this.state["zoom_val"] = Math.max(zoom_val, 0.01); + let floor = 0.01; + // When min_zoom_fit_ratio > 0, refuse to zoom out past a multiple of the + // "whole image just fits" zoom (ratio 1.0 == exactly the fit level). + const fit_ratio = this.config["min_zoom_fit_ratio"]; + if (fit_ratio > 0) { + const fit_zoom = Math.min( + this.get_viewport_height_ratio(this.config["image_height"]), + this.get_viewport_width_ratio(this.config["image_width"]), + ); + if (Number.isFinite(fit_zoom) && fit_zoom > 0) { + floor = Math.max(floor, fit_zoom * fit_ratio); + } + } + this.state["zoom_val"] = Math.max(zoom_val, floor); } // Handle zooming at a certain focus diff --git a/src/listeners.ts b/src/listeners.ts index 4802f13d..e22c0b09 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -483,6 +483,23 @@ export function create_ulabel_listeners( // Store a reference ulabel.resize_observers.push(tb_overflow_resize_observer); + // Re-clamp zoom to `min_zoom_fit_ratio` when the annbox resizes. `set_zoom_val` + // computes the floor from live viewport dimensions, so we just reapply the + // current value; if it lands above the new floor nothing changes. + const min_zoom_resize_observer = new ResizeObserver(() => { + if (!ulabel.is_init) return; + if (!(ulabel.config["min_zoom_fit_ratio"] > 0)) return; + const current = ulabel.state["zoom_val"]; + ulabel.set_zoom_val(current); + if (ulabel.state["zoom_val"] !== current) { + ulabel.rezoom(); + } + }); + min_zoom_resize_observer.observe( + document.getElementById(ulabel.config["annbox_id"])!, + ); + ulabel.resize_observers.push(min_zoom_resize_observer); + // create_soft_id_toolbox_button_listener(ulabel); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- jQuery overloads don't support namespaced event strings ($(document) as any).on( diff --git a/src/toolbox.ts b/src/toolbox.ts index a2e5103a..be7355fd 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -966,9 +966,9 @@ export class ZoomPanToolboxItem extends ToolboxItem { $(document).on("click.ulabel", ".ulabel-zoom-button", (event) => { if ($(event.currentTarget).hasClass("ulabel-zoom-out")) { - this.ulabel.state.zoom_val /= 1.1; + this.ulabel.set_zoom_val(this.ulabel.state.zoom_val / 1.1); } else if ($(event.currentTarget).hasClass("ulabel-zoom-in")) { - this.ulabel.state.zoom_val *= 1.1; + this.ulabel.set_zoom_val(this.ulabel.state.zoom_val * 1.1); } this.ulabel.rezoom(); diff --git a/src/version.js b/src/version.js index 7e324148..84e4b0da 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const ULABEL_VERSION = "0.26.2"; +export const ULABEL_VERSION = "0.26.3"; diff --git a/tests/e2e/min-zoom-fit-ratio.spec.js b/tests/e2e/min-zoom-fit-ratio.spec.js new file mode 100644 index 00000000..f92654c0 --- /dev/null +++ b/tests/e2e/min-zoom-fit-ratio.spec.js @@ -0,0 +1,173 @@ +// End-to-end tests for the `min_zoom_fit_ratio` config option, which floors +// zoom_val at a multiple of the "whole image just fits" zoom so users cannot +// zoom out farther than the image bounds. +import { test, expect } from "./fixtures"; +import { wait_for_ulabel_init } from "../testing-utils/init_utils"; + +/** + * Returns the current zoom_val and the current fit-to-viewport zoom + * (the `show_whole_image` floor) for the running ULabel instance. + * @param {import('@playwright/test').Page} page + */ +async function get_zoom_state(page) { + return await page.evaluate(() => { + const ul = window.ulabel; + const annbox = document.getElementById(ul.config.annbox_id); + const fit_zoom = Math.min( + annbox.clientHeight / ul.config.image_height, + annbox.clientWidth / ul.config.image_width, + ); + return { + zoom_val: ul.state.zoom_val, + fit_zoom, + }; + }); +} + +/** + * Sets `min_zoom_fit_ratio` at runtime. `set_zoom_val` reads the value on every + * call, so this is enough — no reinitialization required. + * @param {import('@playwright/test').Page} page + * @param {number} ratio + */ +async function set_min_zoom_fit_ratio(page, ratio) { + await page.evaluate((r) => { + window.ulabel.config.min_zoom_fit_ratio = r; + }, ratio); +} + +test.describe("min_zoom_fit_ratio", () => { + test("default (0) allows zooming out past the image bounds", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Zoom out repeatedly with the wheel; without a floor, zoom_val drops + // well below the fit zoom. + await page.mouse.move(400, 400); + for (let i = 0; i < 20; i++) { + await page.mouse.wheel(0, 100); + } + await page.waitForTimeout(50); + + const { zoom_val, fit_zoom } = await get_zoom_state(page); + expect(zoom_val).toBeLessThan(fit_zoom); + }); + + test("ratio of 1.0 floors wheel zoom-out at fit-to-viewport", async ({ page }) => { + await wait_for_ulabel_init(page); + await set_min_zoom_fit_ratio(page, 1.0); + + // Zoom out well past the fit level + await page.mouse.move(400, 400); + for (let i = 0; i < 20; i++) { + await page.mouse.wheel(0, 100); + } + await page.waitForTimeout(50); + + const { zoom_val, fit_zoom } = await get_zoom_state(page); + // Allow a tiny floating-point tolerance + expect(zoom_val).toBeGreaterThanOrEqual(fit_zoom - 1e-6); + }); + + test("ratio of 2.0 forces the image to always overflow the viewport", async ({ page }) => { + await wait_for_ulabel_init(page); + await set_min_zoom_fit_ratio(page, 2.0); + + // Zoom out repeatedly + await page.mouse.move(400, 400); + for (let i = 0; i < 20; i++) { + await page.mouse.wheel(0, 100); + } + await page.waitForTimeout(50); + + const { zoom_val, fit_zoom } = await get_zoom_state(page); + expect(zoom_val).toBeGreaterThanOrEqual(fit_zoom * 2 - 1e-6); + }); + + test("zoom-in is unaffected by the floor", async ({ page }) => { + await wait_for_ulabel_init(page); + await set_min_zoom_fit_ratio(page, 1.0); + + const before = await get_zoom_state(page); + + // Zoom in with the wheel + await page.mouse.move(400, 400); + for (let i = 0; i < 5; i++) { + await page.mouse.wheel(0, -100); + } + await page.waitForTimeout(50); + + const after = await get_zoom_state(page); + // Zoom in strictly increased zoom_val, unimpeded by the floor + expect(after.zoom_val).toBeGreaterThan(before.zoom_val); + }); + + test("toolbox zoom-out button also respects the floor", async ({ page }) => { + await wait_for_ulabel_init(page); + await set_min_zoom_fit_ratio(page, 1.0); + + // Click the zoom-out button many times + const zoom_out = page.locator(".ulabel-zoom-button.ulabel-zoom-out").first(); + await expect(zoom_out).toBeVisible(); + for (let i = 0; i < 30; i++) { + await zoom_out.click(); + } + await page.waitForTimeout(50); + + const { zoom_val, fit_zoom } = await get_zoom_state(page); + expect(zoom_val).toBeGreaterThanOrEqual(fit_zoom - 1e-6); + }); + + test("floor re-applies when the annbox resizes", async ({ page }) => { + // Start small so fit_zoom is (relatively) large, then park zoom_val at + // the floor. Growing the viewport enlarges the annbox, which raises + // fit_zoom (larger annbox needs more zoom to still fit the image), so + // the floor rises. The resize observer must lift zoom_val up to match + // without a user gesture. + await page.setViewportSize({ width: 700, height: 500 }); + await wait_for_ulabel_init(page); + await set_min_zoom_fit_ratio(page, 1.0); + + // Zoom out to hit the floor + await page.mouse.move(300, 300); + for (let i = 0; i < 30; i++) { + await page.mouse.wheel(0, 100); + } + await page.waitForTimeout(50); + + const before = await get_zoom_state(page); + expect(before.zoom_val).toBeGreaterThanOrEqual(before.fit_zoom - 1e-6); + + // Grow the viewport — fit_zoom must grow, and the observer must lift + // zoom_val to match. + await page.setViewportSize({ width: 1400, height: 900 }); + // ResizeObserver fires asynchronously; give it a couple of frames + await page.waitForTimeout(150); + + const after = await get_zoom_state(page); + expect(after.fit_zoom).toBeGreaterThan(before.fit_zoom); + expect(after.zoom_val).toBeGreaterThanOrEqual(after.fit_zoom - 1e-6); + }); + + test("resize observer is inert when min_zoom_fit_ratio is 0", async ({ page }) => { + await page.setViewportSize({ width: 700, height: 500 }); + await wait_for_ulabel_init(page); + // Deliberately leave min_zoom_fit_ratio at 0 (default) + + // Zoom out below where any future floor would kick in + await page.mouse.move(300, 300); + for (let i = 0; i < 30; i++) { + await page.mouse.wheel(0, 100); + } + await page.waitForTimeout(50); + + const before = await get_zoom_state(page); + expect(before.zoom_val).toBeLessThan(before.fit_zoom); + + // Grow the viewport — with the floor disabled, zoom_val must stay put. + await page.setViewportSize({ width: 1400, height: 900 }); + await page.waitForTimeout(150); + + const after = await get_zoom_state(page); + expect(Math.abs(after.zoom_val - before.zoom_val)).toBeLessThan(1e-6); + }); +}); diff --git a/tests/e2e/wheel-zoom-focal-point.spec.js b/tests/e2e/wheel-zoom-focal-point.spec.js new file mode 100644 index 00000000..add51935 --- /dev/null +++ b/tests/e2e/wheel-zoom-focal-point.spec.js @@ -0,0 +1,248 @@ +// End-to-end tests for wheel-zoom / drag-zoom focal-point anchoring when the +// ULabel container is offset from the viewport origin. Guards against the +// regression described in ulabel-wheel-zoom-focal-point.md: `handle_wheel` and +// `drag_rezoom` used to pass raw viewport coords into `rezoom`, which expects +// annbox-local coords, so zoom would snap by the annbox's screen offset. +import { test, expect } from "./fixtures"; +import { wait_for_ulabel_init } from "../testing-utils/init_utils"; + +/** + * Returns the imwrap element's bounding rect and the current zoom_val. The + * imwrap wraps the image at its zoomed size, so its viewport-space rect is + * the ground truth for mapping between image pixels and screen pixels. + * @param {import('@playwright/test').Page} page + */ +async function get_imwrap_state(page) { + return await page.evaluate(() => { + const imwrap = document.getElementById(window.ulabel.config.imwrap_id); + const rect = imwrap.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + image_width: window.ulabel.config.image_width, + image_height: window.ulabel.config.image_height, + zoom_val: window.ulabel.state.zoom_val, + }; + }); +} + +/** + * Given an image-pixel coordinate and an imwrap state snapshot, return the + * corresponding viewport (client) coordinate. + */ +function image_to_viewport(image_x, image_y, imwrap_state) { + return { + x: imwrap_state.left + image_x * imwrap_state.width / imwrap_state.image_width, + y: imwrap_state.top + image_y * imwrap_state.height / imwrap_state.image_height, + }; +} + +/** + * Inverse of image_to_viewport. + */ +function viewport_to_image(viewport_x, viewport_y, imwrap_state) { + return { + x: (viewport_x - imwrap_state.left) * imwrap_state.image_width / imwrap_state.width, + y: (viewport_y - imwrap_state.top) * imwrap_state.image_height / imwrap_state.height, + }; +} + +/** + * Returns the annbox's viewport-space bounding rect. + * @param {import('@playwright/test').Page} page + */ +async function get_annbox_rect(page) { + return await page.evaluate(() => { + const annbox = document.getElementById(window.ulabel.config.annbox_id); + return annbox.getBoundingClientRect().toJSON(); + }); +} + +/** + * Deterministically sets zoom_val to `mul` times the "whole image just fits" + * zoom and centers the image, so the imwrap comfortably overflows the annbox + * in both axes. Subsequent `rezoom` calls end up with scroll positions far from + * the browser's `[0, max]` clamp boundaries, letting the focal-point anchoring + * invariant be tested without confounds from browser scroll clamping. + * @param {import('@playwright/test').Page} page + * @param {number} mul + */ +async function setup_deterministic_zoom(page, mul = 4) { + await page.evaluate((m) => { + const ul = window.ulabel; + const annbox = document.getElementById(ul.config.annbox_id); + const fit_zoom = Math.min( + annbox.clientHeight / ul.config.image_height, + annbox.clientWidth / ul.config.image_width, + ); + ul.state.zoom_val = fit_zoom * m; + // Center on the image center so scroll lands near the middle of its + // valid range (imwrap - annbox) / 2 in each axis. + ul.rezoom(ul.config.image_width / 2, ul.config.image_height / 2, true); + }, mul); + await page.waitForTimeout(50); +} + +test.describe("Wheel-zoom focal point (offset container)", () => { + test("wheel zoom keeps the pixel under the cursor anchored", async ({ page }) => { + await wait_for_ulabel_init(page, "/offset-container.html"); + await setup_deterministic_zoom(page); + + const annbox_rect = await get_annbox_rect(page); + // Pick a focal point comfortably inside the annbox but off-center so the + // annbox offset genuinely matters (a centered focal happens to survive + // the buggy formula for symmetric layouts). + const focal = { + x: annbox_rect.left + annbox_rect.width * 0.35, + y: annbox_rect.top + annbox_rect.height * 0.6, + }; + + const before = await get_imwrap_state(page); + const image_point = viewport_to_image(focal.x, focal.y, before); + + // Move mouse to focal and wheel to zoom in + await page.mouse.move(focal.x, focal.y); + await page.mouse.wheel(0, -100); + await page.waitForTimeout(50); + + const after = await get_imwrap_state(page); + // Sanity: zoom actually changed + expect(after.zoom_val).toBeGreaterThan(before.zoom_val); + + // The same image pixel should still sit under the cursor + const new_viewport = image_to_viewport(image_point.x, image_point.y, after); + expect(Math.abs(new_viewport.x - focal.x)).toBeLessThanOrEqual(2); + expect(Math.abs(new_viewport.y - focal.y)).toBeLessThanOrEqual(2); + }); + + test("wheel zoom out keeps the pixel under the cursor anchored", async ({ page }) => { + await wait_for_ulabel_init(page, "/offset-container.html"); + await setup_deterministic_zoom(page); + + const annbox_rect = await get_annbox_rect(page); + const focal = { + x: annbox_rect.left + annbox_rect.width * 0.7, + y: annbox_rect.top + annbox_rect.height * 0.3, + }; + + const before = await get_imwrap_state(page); + const image_point = viewport_to_image(focal.x, focal.y, before); + + await page.mouse.move(focal.x, focal.y); + await page.mouse.wheel(0, 100); + await page.waitForTimeout(50); + + const after = await get_imwrap_state(page); + expect(after.zoom_val).toBeLessThan(before.zoom_val); + + const new_viewport = image_to_viewport(image_point.x, image_point.y, after); + expect(Math.abs(new_viewport.x - focal.x)).toBeLessThanOrEqual(2); + expect(Math.abs(new_viewport.y - focal.y)).toBeLessThanOrEqual(2); + }); + + test("shift+drag zoom keeps the mousedown pixel anchored", async ({ page }) => { + await wait_for_ulabel_init(page, "/offset-container.html"); + await setup_deterministic_zoom(page); + + const annbox_rect = await get_annbox_rect(page); + const start = { + x: annbox_rect.left + annbox_rect.width * 0.4, + y: annbox_rect.top + annbox_rect.height * 0.55, + }; + + const before = await get_imwrap_state(page); + const image_point = viewport_to_image(start.x, start.y, before); + + // Shift+drag upward zooms in; drag_rezoom uses the mousedown point as + // the focal, so the image pixel at `start` should stay under `start`. + await page.keyboard.down("Shift"); + await page.mouse.move(start.x, start.y); + await page.mouse.down({ button: "left" }); + await page.mouse.move(start.x, start.y - 150, { steps: 10 }); + await page.mouse.up({ button: "left" }); + await page.keyboard.up("Shift"); + await page.waitForTimeout(50); + + const after = await get_imwrap_state(page); + expect(after.zoom_val).toBeGreaterThan(before.zoom_val); + + const new_viewport = image_to_viewport(image_point.x, image_point.y, after); + // Slightly looser than the 2 px used for a single wheel step: drag_rezoom runs + // for each of the ~10 dispatched mousemove events, so small per-iteration + // rounding is expected to accumulate a few pixels. + expect(Math.abs(new_viewport.x - start.x)).toBeLessThanOrEqual(5); + expect(Math.abs(new_viewport.y - start.y)).toBeLessThanOrEqual(5); + }); +}); + +test.describe("Wheel-zoom focal point (non-offset container regression)", () => { + test("wheel zoom still anchors correctly when container is at viewport origin", async ({ page }) => { + // multi-class.html positions the container at (0, 0), so this exercises + // the codepath that was already working and would not regress from the + // fix (rect.left == 0, rect.top == 0). + await wait_for_ulabel_init(page, "/multi-class.html"); + + const annbox_rect = await get_annbox_rect(page); + const focal = { + x: annbox_rect.left + annbox_rect.width * 0.35, + y: annbox_rect.top + annbox_rect.height * 0.6, + }; + + const before = await get_imwrap_state(page); + const image_point = viewport_to_image(focal.x, focal.y, before); + + await page.mouse.move(focal.x, focal.y); + await page.mouse.wheel(0, -100); + await page.waitForTimeout(50); + + const after = await get_imwrap_state(page); + expect(after.zoom_val).toBeGreaterThan(before.zoom_val); + + const new_viewport = image_to_viewport(image_point.x, image_point.y, after); + expect(Math.abs(new_viewport.x - focal.x)).toBeLessThanOrEqual(2); + expect(Math.abs(new_viewport.y - focal.y)).toBeLessThanOrEqual(2); + }); +}); + +test.describe("Wheel-zoom focal point (scaled ancestor)", () => { + test("wheel zoom anchors correctly under a CSS-scaled ancestor", async ({ page }) => { + // The offset demo already has an ancestor wrapper (#offset-wrapper) we can + // scale from the origin. Use scale(0.5) so the annbox's rendered size is + // half of its layout size; this exercises the scale-aware conversion in + // `viewport_to_annbox_local`. + await wait_for_ulabel_init(page, "/offset-container.html"); + await page.evaluate(() => { + const wrap = document.getElementById("offset-wrapper"); + wrap.style.transformOrigin = "0 0"; + wrap.style.transform = "scale(0.5)"; + }); + // Give the layout a frame to settle after transform + await page.waitForTimeout(50); + await setup_deterministic_zoom(page); + + const annbox_rect = await get_annbox_rect(page); + const focal = { + x: annbox_rect.left + annbox_rect.width * 0.35, + y: annbox_rect.top + annbox_rect.height * 0.6, + }; + + const before = await get_imwrap_state(page); + const image_point = viewport_to_image(focal.x, focal.y, before); + + await page.mouse.move(focal.x, focal.y); + await page.mouse.wheel(0, -100); + await page.waitForTimeout(50); + + const after = await get_imwrap_state(page); + expect(after.zoom_val).toBeGreaterThan(before.zoom_val); + + // Under scale(0.5) the annbox is half its layout size on screen, so a + // 1-layout-pixel drift is 0.5 rendered pixels. Keep tolerance loose in + // case cross-browser rounding differs. + const new_viewport = image_to_viewport(image_point.x, image_point.y, after); + expect(Math.abs(new_viewport.x - focal.x)).toBeLessThanOrEqual(2); + expect(Math.abs(new_viewport.y - focal.y)).toBeLessThanOrEqual(2); + }); +});