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"