Skip to content

Commit 50515ba

Browse files
Merge pull request #244 from SenteraLLC/fix/vanish-scroll
Fix/vanish scroll
2 parents 92410ea + 9b1e4d5 commit 50515ba

6 files changed

Lines changed: 293 additions & 7 deletions

File tree

.github/workflows/test.yml

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,29 @@ jobs:
4343

4444
- name: Install dependencies
4545
run: npm install
46-
46+
47+
- name: Get installed Playwright version
48+
id: playwright-version
49+
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
50+
51+
- name: Cache Playwright browsers
52+
id: playwright-cache
53+
uses: actions/cache@v4
54+
with:
55+
path: ~/.cache/ms-playwright
56+
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
57+
4758
- name: Install Playwright browsers
59+
if: steps.playwright-cache.outputs.cache-hit != 'true'
4860
run: npx playwright install --with-deps
49-
61+
62+
- name: Install Playwright system dependencies (cache hit)
63+
if: steps.playwright-cache.outputs.cache-hit == 'true'
64+
run: npx playwright install-deps
65+
5066
- name: Build project
5167
run: npm run build
52-
68+
5369
- name: Run E2E tests (all browsers, both builds)
5470
run: npm run test:e2e
5571

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ All notable changes to this project will be documented here.
44

55
## [unreleased]
66

7+
## [0.23.5] - May 6th, 2026
8+
- Fix bug where on some browsers, middle-click-drag when annotations were vanished would trigger auto-scroll rather than the normal pan behavior.
9+
710
## [0.23.4] - May 5th, 2026
811
- Fix type declarations for npm consumers
912
- Add `"files"` field to `package.json` to explicitly control published package contents.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "ulabel",
33
"description": "An image annotation tool.",
4-
"version": "0.23.4",
4+
"version": "0.23.5",
55
"main": "dist/ulabel.min.js",
66
"module": "dist/ulabel.min.js",
77
"types": "index.d.ts",

src/index.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5458,14 +5458,15 @@ export class ULabel {
54585458
handle_mouse_down(mouse_event) {
54595459
const drag_key = ULabel.get_drag_key_start(mouse_event, this);
54605460
if (drag_key != null) {
5461+
// Suppress browser defaults (e.g. middle-click auto-scroll)
5462+
mouse_event.preventDefault();
54615463
// Don't start new drag while id_dialog is visible or subtask is vanished
54625464
if (
54635465
(this.get_current_subtask()["state"]["idd_visible"] && !this.get_current_subtask()["state"]["idd_thumbnail"]) ||
5464-
this.get_current_subtask()["state"]["is_vanished"]
5466+
(this.get_current_subtask()["state"]["is_vanished"] && drag_key !== "pan" && drag_key !== "zoom")
54655467
) {
54665468
return;
54675469
}
5468-
mouse_event.preventDefault();
54695470
if (this.drag_state["active_key"] === null) {
54705471
this.start_drag(drag_key, mouse_event.button, mouse_event);
54715472
}

src/version.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const ULABEL_VERSION = "0.23.4";
1+
export const ULABEL_VERSION = "0.23.5";

tests/e2e/zoom-pan.spec.js

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// End-to-end tests for zoom and pan interactions
2+
import { test, expect } from "./fixtures";
3+
import { wait_for_ulabel_init } from "../testing-utils/init_utils";
4+
5+
/**
6+
* Returns the current zoom_val from the ULabel instance.
7+
* @param {import('@playwright/test').Page} page
8+
*/
9+
async function get_zoom_val(page) {
10+
return await page.evaluate(() => window.ulabel.state.zoom_val);
11+
}
12+
13+
/**
14+
* Returns the current annbox scroll position {left, top}.
15+
* @param {import('@playwright/test').Page} page
16+
*/
17+
async function get_annbox_scroll(page) {
18+
return await page.evaluate(() => {
19+
const annbox = document.getElementById(window.ulabel.config.annbox_id);
20+
return { left: annbox.scrollLeft, top: annbox.scrollTop };
21+
});
22+
}
23+
24+
/**
25+
* Returns the canvas element id for the current subtask.
26+
* @param {import('@playwright/test').Page} page
27+
*/
28+
async function get_canvas_fid(page) {
29+
return await page.evaluate(() => window.ulabel.get_current_subtask().canvas_fid);
30+
}
31+
32+
/**
33+
* Performs a click-drag from start to end with the given mouse button and
34+
* optional modifier keys.
35+
* @param {import('@playwright/test').Page} page
36+
* @param {{x: number, y: number}} start
37+
* @param {{x: number, y: number}} end
38+
* @param {{button?: "left"|"middle"|"right", modifiers?: string[], steps?: number}} opts
39+
*/
40+
async function click_drag(page, start, end, opts = {}) {
41+
const button = opts.button ?? "left";
42+
const modifiers = opts.modifiers ?? [];
43+
const steps = opts.steps ?? 10;
44+
45+
for (const mod of modifiers) {
46+
await page.keyboard.down(mod);
47+
}
48+
await page.mouse.move(start.x, start.y);
49+
await page.mouse.down({ button });
50+
await page.mouse.move(end.x, end.y, { steps });
51+
await page.mouse.up({ button });
52+
for (const mod of modifiers) {
53+
await page.keyboard.up(mod);
54+
}
55+
}
56+
57+
test.describe("Zoom and Pan Interactions", () => {
58+
test("mouse wheel up zooms in", async ({ page }) => {
59+
await wait_for_ulabel_init(page);
60+
61+
const initial_zoom = await get_zoom_val(page);
62+
63+
// Move into the annbox before scrolling
64+
await page.mouse.move(400, 400);
65+
await page.mouse.wheel(0, -100); // negative deltaY = zoom in
66+
await page.waitForTimeout(50);
67+
68+
const new_zoom = await get_zoom_val(page);
69+
expect(new_zoom).toBeGreaterThan(initial_zoom);
70+
});
71+
72+
test("mouse wheel down zooms out", async ({ page }) => {
73+
await wait_for_ulabel_init(page);
74+
75+
// Zoom in first so we have room to zoom out
76+
await page.mouse.move(400, 400);
77+
await page.mouse.wheel(0, -200);
78+
await page.waitForTimeout(50);
79+
80+
const before_out = await get_zoom_val(page);
81+
82+
await page.mouse.wheel(0, 100); // positive deltaY = zoom out
83+
await page.waitForTimeout(50);
84+
85+
const after_out = await get_zoom_val(page);
86+
expect(after_out).toBeLessThan(before_out);
87+
});
88+
89+
test("middle-click drag pans the annbox", async ({ page }) => {
90+
await wait_for_ulabel_init(page);
91+
92+
// Zoom in so the image is larger than the viewport and can be panned
93+
await page.mouse.move(400, 400);
94+
await page.mouse.wheel(0, -300);
95+
await page.waitForTimeout(50);
96+
97+
const before = await get_annbox_scroll(page);
98+
99+
// Drag from (500, 400) to (300, 250) — should pan the view
100+
await click_drag(page, { x: 500, y: 400 }, { x: 300, y: 250 }, { button: "middle" });
101+
await page.waitForTimeout(50);
102+
103+
const after = await get_annbox_scroll(page);
104+
// Pan should change at least one of the scroll positions
105+
expect(after.left !== before.left || after.top !== before.top).toBe(true);
106+
});
107+
108+
test("ctrl+left-click drag on canvas pans the annbox", async ({ page }) => {
109+
await wait_for_ulabel_init(page);
110+
111+
// Zoom in so we can pan
112+
await page.mouse.move(400, 400);
113+
await page.mouse.wheel(0, -300);
114+
await page.waitForTimeout(50);
115+
116+
const canvas_fid = await get_canvas_fid(page);
117+
const canvas = page.locator(`#${canvas_fid}`);
118+
const box = await canvas.boundingBox();
119+
expect(box).not.toBeNull();
120+
121+
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
122+
const end = { x: start.x - 80, y: start.y - 60 };
123+
124+
const before = await get_annbox_scroll(page);
125+
await click_drag(page, start, end, { button: "left", modifiers: ["Control"] });
126+
await page.waitForTimeout(50);
127+
const after = await get_annbox_scroll(page);
128+
129+
expect(after.left !== before.left || after.top !== before.top).toBe(true);
130+
});
131+
132+
test("shift+left-click drag on canvas zooms", async ({ page }) => {
133+
await wait_for_ulabel_init(page);
134+
135+
const canvas_fid = await get_canvas_fid(page);
136+
const canvas = page.locator(`#${canvas_fid}`);
137+
const box = await canvas.boundingBox();
138+
expect(box).not.toBeNull();
139+
140+
const before_zoom = await get_zoom_val(page);
141+
142+
// Drag upward — drag_rezoom raises zoom when mouse moves up
143+
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
144+
const end = { x: start.x, y: start.y - 150 };
145+
146+
await click_drag(page, start, end, { button: "left", modifiers: ["Shift"] });
147+
await page.waitForTimeout(50);
148+
149+
const after_zoom = await get_zoom_val(page);
150+
expect(after_zoom).toBeGreaterThan(before_zoom);
151+
});
152+
153+
test("shift+left-click drag still zooms when subtask is vanished", async ({ page }) => {
154+
await wait_for_ulabel_init(page);
155+
156+
// Vanish the current subtask
157+
await page.evaluate(() => {
158+
window.ulabel.get_current_subtask().state.is_vanished = true;
159+
});
160+
161+
const canvas_fid = await get_canvas_fid(page);
162+
const canvas = page.locator(`#${canvas_fid}`);
163+
const box = await canvas.boundingBox();
164+
expect(box).not.toBeNull();
165+
166+
const before_zoom = await get_zoom_val(page);
167+
168+
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
169+
const end = { x: start.x, y: start.y - 150 };
170+
171+
await click_drag(page, start, end, { button: "left", modifiers: ["Shift"] });
172+
await page.waitForTimeout(50);
173+
174+
const after_zoom = await get_zoom_val(page);
175+
expect(after_zoom).toBeGreaterThan(before_zoom);
176+
});
177+
178+
test("middle-click drag still pans when subtask is vanished and does not start an annotation drag", async ({ page }) => {
179+
await wait_for_ulabel_init(page);
180+
181+
// Zoom in so the image overflows the viewport (otherwise scroll positions
182+
// cannot change anyway, which would make the assertion meaningless).
183+
await page.mouse.move(400, 400);
184+
await page.mouse.wheel(0, -300);
185+
await page.waitForTimeout(50);
186+
187+
// Vanish the current subtask
188+
await page.evaluate(() => {
189+
window.ulabel.get_current_subtask().state.is_vanished = true;
190+
});
191+
192+
// Record annotation count and observe drag_state.active_key during the drag
193+
// by patching start_drag — this lets us catch any annotation drag that may
194+
// start before mouseup clears active_key back to null.
195+
const initial_annotation_count = await page.evaluate(() => {
196+
const st = window.ulabel.get_current_subtask();
197+
return Object.keys(st.annotations.access).length;
198+
});
199+
await page.evaluate(() => {
200+
window.__observed_drag_keys = [];
201+
const ul = window.ulabel;
202+
const original_start_drag = ul.start_drag.bind(ul);
203+
ul.start_drag = function (drag_key, mouse_button, mouse_event) {
204+
window.__observed_drag_keys.push(drag_key);
205+
return original_start_drag(drag_key, mouse_button, mouse_event);
206+
};
207+
});
208+
209+
const before = await get_annbox_scroll(page);
210+
await click_drag(page, { x: 500, y: 400 }, { x: 300, y: 250 }, { button: "middle" });
211+
await page.waitForTimeout(50);
212+
const after = await get_annbox_scroll(page);
213+
214+
// Pan must have moved the annbox scroll position
215+
expect(after.left !== before.left || after.top !== before.top).toBe(true);
216+
217+
// Only a "pan" drag should have started — no annotation drag
218+
const observed = await page.evaluate(() => window.__observed_drag_keys);
219+
expect(observed).toEqual(["pan"]);
220+
221+
// No annotation should have been created
222+
const final_annotation_count = await page.evaluate(() => {
223+
const st = window.ulabel.get_current_subtask();
224+
return Object.keys(st.annotations.access).length;
225+
});
226+
expect(final_annotation_count).toBe(initial_annotation_count);
227+
});
228+
229+
test("annotation drag is blocked when subtask is vanished", async ({ page }) => {
230+
await wait_for_ulabel_init(page);
231+
232+
// Ensure bbox mode
233+
await page.click("a#md-btn--bbox");
234+
await page.waitForTimeout(50);
235+
236+
// Vanish the current subtask
237+
await page.evaluate(() => {
238+
window.ulabel.get_current_subtask().state.is_vanished = true;
239+
});
240+
241+
const initial_count = await page.evaluate(() => {
242+
const st = window.ulabel.get_current_subtask();
243+
return Object.keys(st.annotations.access).length;
244+
});
245+
246+
// Attempt to draw a bbox via plain left-drag on the canvas
247+
const canvas_fid = await get_canvas_fid(page);
248+
const canvas = page.locator(`#${canvas_fid}`);
249+
const box = await canvas.boundingBox();
250+
const start = { x: box.x + box.width / 2 - 60, y: box.y + box.height / 2 - 40 };
251+
const end = { x: start.x + 120, y: start.y + 80 };
252+
await click_drag(page, start, end, { button: "left" });
253+
await page.waitForTimeout(100);
254+
255+
// No annotation should have been created — annotation drags are blocked while vanished
256+
const final_count = await page.evaluate(() => {
257+
const st = window.ulabel.get_current_subtask();
258+
return Object.keys(st.annotations.access).length;
259+
});
260+
expect(final_count).toBe(initial_count);
261+
262+
// drag_state.active_key should still be null
263+
const active_key = await page.evaluate(() => window.ulabel.drag_state.active_key);
264+
expect(active_key).toBeNull();
265+
});
266+
});

0 commit comments

Comments
 (0)