diff --git a/label_studio/templates/base.html b/label_studio/templates/base.html
index 17b9fac6a0c2..a8c41d45d802 100644
--- a/label_studio/templates/base.html
+++ b/label_studio/templates/base.html
@@ -105,7 +105,7 @@
// Filter custom hotkeys for editor-specific ones
var editorCustomHotkeys = {};
- var prefixRegex = /^(annotation|timeseries|audio|regions|video|image_gallery|tools):(.*)/;
+ var prefixRegex = /^(annotation|timeseries|audio|regions|video|image_gallery|tools|zoomed_image):(.*)/;
for (let key in __customHotkeys) {
const match = key.match(prefixRegex);
diff --git a/web/libs/app-common/src/pages/AccountSettings/hooks/useHotkeys.ts b/web/libs/app-common/src/pages/AccountSettings/hooks/useHotkeys.ts
index 96f741cb620d..9da8f1fe1f26 100644
--- a/web/libs/app-common/src/pages/AccountSettings/hooks/useHotkeys.ts
+++ b/web/libs/app-common/src/pages/AccountSettings/hooks/useHotkeys.ts
@@ -67,7 +67,7 @@ export const useHotkeys = () => {
// Transform custom hotkeys to editor format (same logic as base.html)
const editorCustomHotkeys: Record = {};
- const prefixRegex = /^(annotation|timeseries|audio|regions|video|image_gallery|tools):(.*)/;
+ const prefixRegex = /^(annotation|timeseries|audio|regions|video|image_gallery|tools|zoomed_image):(.*)/;
for (const key in customHotkeys) {
const match = key.match(prefixRegex);
diff --git a/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/Item.tsx b/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/Item.tsx
index f32d6f6637a8..cc5e136d5923 100644
--- a/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/Item.tsx
+++ b/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/Item.tsx
@@ -29,6 +29,15 @@ interface HotkeyItemProps {
onToggle: (id: string) => void;
}
+const KEY_ALIASES: Record = {
+ ArrowLeft: "left",
+ ArrowRight: "right",
+ ArrowUp: "up",
+ ArrowDown: "down",
+};
+
+const normalizeKey = (key: string): string => KEY_ALIASES[key] ?? key.toLowerCase();
+
/**
* HotkeyItem component for displaying and editing keyboard shortcuts
*
@@ -80,7 +89,7 @@ export const HotkeyItem = ({ hotkey, onEdit, isEditing, onSave, onCancel, onTogg
if (altKey) keyCombo.push("alt");
if (metaKey) keyCombo.push("meta");
- keyCombo.push(key.toLowerCase());
+ keyCombo.push(normalizeKey(key));
setEditedKey(keyCombo.join("+"));
setError("");
diff --git a/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/defaults.js b/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/defaults.js
index 259d2d3bee91..50262d4434d2 100644
--- a/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/defaults.js
+++ b/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/defaults.js
@@ -454,6 +454,42 @@ export const DEFAULT_HOTKEYS = [
description: "Pan around the image",
active: true,
},
+ {
+ id: 5150,
+ section: "zoomed_image",
+ element: "tool:pan-on-zoom-left",
+ label: "Pan Left",
+ key: "left",
+ description: "Pan a zoomed-in image left with the keyboard",
+ active: true,
+ },
+ {
+ id: 5160,
+ section: "zoomed_image",
+ element: "tool:pan-on-zoom-right",
+ label: "Pan Right",
+ key: "right",
+ description: "Pan a zoomed-in image right with the keyboard",
+ active: true,
+ },
+ {
+ id: 5170,
+ section: "zoomed_image",
+ element: "tool:pan-on-zoom-up",
+ label: "Pan Up",
+ key: "up",
+ description: "Pan a zoomed-in image up with the keyboard",
+ active: true,
+ },
+ {
+ id: 5180,
+ section: "zoomed_image",
+ element: "tool:pan-on-zoom-down",
+ label: "Pan Down",
+ key: "down",
+ description: "Pan a zoomed-in image down with the keyboard",
+ active: true,
+ },
{
id: 5200,
section: "tools",
@@ -703,6 +739,11 @@ export const HOTKEY_SECTIONS = [
title: "Image Gallery Navigation",
description: "Shortcuts for navigating between images in multi-image tasks",
},
+ {
+ id: "zoomed_image",
+ title: "Zoomed Image Controls",
+ description: "Shortcuts for panning a zoomed-in image with the keyboard.",
+ },
{
id: "paragraphs",
title: "Paragraph Navigation",
diff --git a/web/libs/editor/src/components/ImageView/ImageView.jsx b/web/libs/editor/src/components/ImageView/ImageView.jsx
index 7e9be8ca7770..14ee1e47f366 100644
--- a/web/libs/editor/src/components/ImageView/ImageView.jsx
+++ b/web/libs/editor/src/components/ImageView/ImageView.jsx
@@ -29,6 +29,106 @@ Konva.showWarnings = false;
const hotkeys = Hotkey("Image");
const imgDefaultProps = { crossOrigin: "anonymous" };
+// --- Keyboard panning of a zoomed image -------------------------------------
+// Arrow keys pan the visible window of a zoomed-in image.
+// Behavior:
+// - only acts while item.zoomScale > 1 (gated to zoomed images)
+// - multi-image valueList is handled for free (setZoomPosition targets the
+// current image entity)
+// - multiple tags: the last-interacted image wins (interaction =
+// mousedown on its canvas or wheel zoom/pan over it)
+const PAN_STEP = 0.1; // fraction of the visible canvas moved per key press
+
+// Direction -> [signX, signY] for zoomingPosition. Matches the trackpad scroll
+// convention in handleZoom (right/down reveal content in that direction).
+const PAN_ON_ZOOM_HOTKEYS = {
+ "tool:pan-on-zoom-left": [1, 0],
+ "tool:pan-on-zoom-right": [-1, 0],
+ "tool:pan-on-zoom-up": [0, 1],
+ "tool:pan-on-zoom-down": [0, -1],
+};
+
+const mountedImages = new Set();
+let lastInteractedImage = null;
+let imagePanHotkeyRegistered = false;
+
+const setLastInteractedImage = (item) => {
+ if (item) {
+ lastInteractedImage = item;
+ syncImagePanHotkey();
+ }
+};
+
+const isEditableTarget = () => {
+ const el = document.activeElement;
+ if (!el) return false;
+ return /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable;
+};
+
+const isZoomedImage = (item) => isAlive(item) && item.zoomScale > 1;
+
+const resolvePanTarget = () => {
+ let target =
+ lastInteractedImage && mountedImages.has(lastInteractedImage) && isZoomedImage(lastInteractedImage)
+ ? lastInteractedImage
+ : null;
+
+ // No clear last-interacted image, but exactly one is zoomed - pan that one.
+ if (!target) {
+ const zoomed = [...mountedImages].filter(isZoomedImage);
+ if (zoomed.length === 1) target = zoomed[0];
+ }
+ if (!target) return null;
+
+ return target;
+};
+
+const panZoomedImage = (dir) => (e) => {
+ if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return; // modifier combos belong to other tools
+ if (isEditableTarget()) return;
+
+ const item = resolvePanTarget();
+
+ if (!item) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ const { width, height } = item.canvasSize;
+
+ item.setZoomPosition(
+ item.zoomingPositionX + dir[0] * width * PAN_STEP,
+ item.zoomingPositionY + dir[1] * height * PAN_STEP,
+ );
+};
+
+function syncImagePanHotkey() {
+ const shouldRegister = Boolean(resolvePanTarget());
+
+ if (shouldRegister && !imagePanHotkeyRegistered) {
+ Object.entries(PAN_ON_ZOOM_HOTKEYS).forEach(([name, dir]) => {
+ hotkeys.addNamed(name, panZoomedImage(dir));
+ });
+ imagePanHotkeyRegistered = true;
+ } else if (!shouldRegister && imagePanHotkeyRegistered) {
+ Object.keys(PAN_ON_ZOOM_HOTKEYS).forEach((name) => {
+ hotkeys.removeNamed(name);
+ });
+ imagePanHotkeyRegistered = false;
+ }
+}
+
+const registerImagePan = (item) => {
+ mountedImages.add(item);
+ syncImagePanHotkey();
+};
+
+const unregisterImagePan = (item) => {
+ mountedImages.delete(item);
+ if (lastInteractedImage === item) lastInteractedImage = null;
+ syncImagePanHotkey();
+};
+
export const splitRegions = (regions) => {
const brushRegions = [];
const shapeRegions = [];
@@ -619,6 +719,9 @@ export default observer(
handleMouseDown = (e) => {
this.mouseDown = true;
const { item } = this.props;
+
+ setLastInteractedImage(item);
+
const isPanTool = item.getToolsManager().findSelectedTool()?.fullName === "ZoomPanTool";
const isMoveTool = item.getToolsManager().findSelectedTool()?.fullName === "MoveTool";
@@ -872,6 +975,8 @@ export default observer(
* - Two-finger scroll: Pan the image when zoomed in
*/
handleZoom = (e) => {
+ setLastInteractedImage(this.props.item);
+
if (e.evt?.ctrlKey || e.evt?.metaKey) {
e.evt.preventDefault();
@@ -880,6 +985,7 @@ export default observer(
// Unified smooth zoom behavior for both trackpad and mouse wheel
item.handleZoom(e.evt.deltaY, stage.getPointerPosition(), e.evt.ctrlKey);
+ syncImagePanHotkey();
} else if (e.evt) {
// Two fingers scroll (panning) - only when zoomed in
const { item } = this.props;
@@ -964,6 +1070,8 @@ export default observer(
this.attachObserver(item.containerRef);
this.updateReadyStatus();
+ registerImagePan(item);
+
hotkeys.addDescription("shift", "Pan image");
}
@@ -989,12 +1097,15 @@ export default observer(
window.removeEventListener("mousemove", this.handleGlobalMouseMove);
window.removeEventListener("mouseup", this.handleGlobalMouseUp);
+ unregisterImagePan(this.props.item);
+
hotkeys.removeDescription("shift");
}
componentDidUpdate() {
this.onResize();
this.updateReadyStatus();
+ syncImagePanHotkey();
}
updateReadyStatus() {
diff --git a/web/libs/editor/src/components/ImageView/__tests__/ImageView.test.jsx b/web/libs/editor/src/components/ImageView/__tests__/ImageView.test.jsx
index 63f942a61a27..78e0472e51cc 100644
--- a/web/libs/editor/src/components/ImageView/__tests__/ImageView.test.jsx
+++ b/web/libs/editor/src/components/ImageView/__tests__/ImageView.test.jsx
@@ -5,6 +5,8 @@ import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import ImageView, { splitRegions } from "../ImageView";
+var mockHotkey;
+
jest.mock("../../../utils/feature-flags", () => ({
isFF: jest.fn(() => false),
FF_DEV_1442: "fflag_dev_1442",
@@ -102,10 +104,16 @@ jest.mock("../../../utils/resize-observer", () => ({
jest.mock("../../../core/Hotkey", () => ({
__esModule: true,
- Hotkey: jest.fn(() => ({
- addDescription: jest.fn(),
- removeDescription: jest.fn(),
- })),
+ Hotkey: jest.fn(() => {
+ mockHotkey = mockHotkey ?? {
+ addDescription: jest.fn(),
+ removeDescription: jest.fn(),
+ addNamed: jest.fn(),
+ removeNamed: jest.fn(),
+ };
+
+ return mockHotkey;
+ }),
}));
jest.mock("mobx-state-tree", () => ({
@@ -388,6 +396,83 @@ describe("ImageView", () => {
expect(item.zoom).toBe(true);
});
+ it("does not register pan-on-zoom hotkeys when no image is zoomed", () => {
+ const store = createStore();
+ const item = createItem({ zoomScale: 1 });
+
+ item.store = store;
+ render();
+
+ expect(mockHotkey.addNamed).not.toHaveBeenCalledWith("tool:pan-on-zoom-left", expect.any(Function));
+ expect(mockHotkey.addNamed).not.toHaveBeenCalledWith("tool:pan-on-zoom-right", expect.any(Function));
+ expect(mockHotkey.addNamed).not.toHaveBeenCalledWith("tool:pan-on-zoom-up", expect.any(Function));
+ expect(mockHotkey.addNamed).not.toHaveBeenCalledWith("tool:pan-on-zoom-down", expect.any(Function));
+ });
+
+ it("registers directional pan-on-zoom hotkeys and pans the zoomed image", () => {
+ const store = createStore();
+ const item = createItem({ zoomScale: 2, zoomingPositionX: 0, zoomingPositionY: 0 });
+
+ item.store = store;
+ render();
+
+ const registeredNames = mockHotkey.addNamed.mock.calls.map(([name]) => name);
+ const callback = mockHotkey.addNamed.mock.calls.find(([name]) => name === "tool:pan-on-zoom-right")?.[1];
+ const event = {
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ };
+
+ callback?.(event);
+
+ expect(registeredNames).toEqual(
+ expect.arrayContaining([
+ "tool:pan-on-zoom-left",
+ "tool:pan-on-zoom-right",
+ "tool:pan-on-zoom-up",
+ "tool:pan-on-zoom-down",
+ ]),
+ );
+ expect(callback).toBeDefined();
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.stopPropagation).toHaveBeenCalled();
+ expect(item.setZoomPosition).toHaveBeenCalledWith(-40, 0);
+ });
+
+ it("registers pan-on-zoom when TimeSeries is present", () => {
+ const store = createStore();
+ const item = createItem({
+ zoomScale: 2,
+ zoomingPositionX: 0,
+ zoomingPositionY: 0,
+ annotation: {
+ isReadOnly: () => false,
+ selectedRegions: [],
+ unselectAll: jest.fn(),
+ unselectAreas: jest.fn(),
+ isDrawing: false,
+ isLinkingMode: false,
+ objects: [{ type: "timeseries" }],
+ },
+ });
+
+ item.store = store;
+ render();
+
+ const callback = mockHotkey.addNamed.mock.calls.find(([name]) => name === "tool:pan-on-zoom-right")?.[1];
+
+ callback?.({
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ });
+
+ expect(mockHotkey.addNamed).toHaveBeenCalledWith("tool:pan-on-zoom-left", expect.any(Function));
+ expect(mockHotkey.addNamed).toHaveBeenCalledWith("tool:pan-on-zoom-right", expect.any(Function));
+ expect(mockHotkey.addNamed).toHaveBeenCalledWith("tool:pan-on-zoom-up", expect.any(Function));
+ expect(mockHotkey.addNamed).toHaveBeenCalledWith("tool:pan-on-zoom-down", expect.any(Function));
+ expect(item.setZoomPosition).toHaveBeenCalledWith(-40, 0);
+ });
+
it("handleZoom with ctrlKey calls item.handleZoom when invoked via ref", () => {
const store = createStore();
const item = createItem({ zoom: true });
diff --git a/web/libs/editor/src/core/settings/keymap.json b/web/libs/editor/src/core/settings/keymap.json
index 2c3a4d330636..9598e0486a64 100644
--- a/web/libs/editor/src/core/settings/keymap.json
+++ b/web/libs/editor/src/core/settings/keymap.json
@@ -226,6 +226,26 @@
"description": "Pan around the image"
},
+ "tool:pan-on-zoom-left": {
+ "key": "left",
+ "description": "Pan a zoomed image left"
+ },
+
+ "tool:pan-on-zoom-right": {
+ "key": "right",
+ "description": "Pan a zoomed image right"
+ },
+
+ "tool:pan-on-zoom-up": {
+ "key": "up",
+ "description": "Pan a zoomed image up"
+ },
+
+ "tool:pan-on-zoom-down": {
+ "key": "down",
+ "description": "Pan a zoomed image down"
+ },
+
"tool:zoom-to-fit": {
"key": "shift+1",
"description": "Zoom to fit the full image in view"