Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion label_studio/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const useHotkeys = () => {

// Transform custom hotkeys to editor format (same logic as base.html)
const editorCustomHotkeys: Record<string, any> = {};
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ interface HotkeyItemProps {
onToggle: (id: string) => void;
}

const KEY_ALIASES: Record<string, string> = {
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
*
Expand Down Expand Up @@ -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("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
111 changes: 111 additions & 0 deletions web/libs/editor/src/components/ImageView/ImageView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Image> 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 = [];
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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();

Expand All @@ -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;
Expand Down Expand Up @@ -964,6 +1070,8 @@ export default observer(
this.attachObserver(item.containerRef);
this.updateReadyStatus();

registerImagePan(item);

hotkeys.addDescription("shift", "Pan image");
}

Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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(<ImageView item={item} store={store} />);

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(<ImageView item={item} store={store} />);

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(<ImageView item={item} store={store} />);

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 });
Expand Down
20 changes: 20 additions & 0 deletions web/libs/editor/src/core/settings/keymap.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading