From e5d54c75dd883b8b672ed515d77f7c037edccb19 Mon Sep 17 00:00:00 2001 From: hlomzik Date: Wed, 18 Dec 2024 13:42:03 +0000 Subject: [PATCH] feat: LEAP-1424: LEAP-1370: Allow to zoom and pan the image in preview modal (#6781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow users to zoom in and pan images in preview modal, which you can open from Grid View. Interactions are intuitive — scroll zooms and drag pans. This is also described in a tooltip. This PR also includes https://github.com/HumanSignal/label-studio/pull/6748 for LEAP-1424: Open a simple modal with image from the data of clicked task. Allow to navigate between tasks visible in Grid view by Prev/Next buttons. Only one image is displayed right now. Simple common hotkeys are available: - `` to switch between tasks - `` to select/unselect task - `` to close the modal Co-authored-by: bmartel Co-authored-by: MihajloHoma Co-authored-by: hlomzik --- .../src/components/Common/Table/utils.js | 3 +- .../MainView/GridView/GridPreview.module.scss | 62 ++++++ .../MainView/GridView/GridPreview.tsx | 178 +++++++++++++++ .../components/MainView/GridView/GridView.jsx | 94 ++++---- .../MainView/GridView/ImagePreview.tsx | 205 ++++++++++++++++++ .../datamanager/src/utils/feature-flags.js | 3 + 6 files changed, 505 insertions(+), 40 deletions(-) create mode 100644 web/libs/datamanager/src/components/MainView/GridView/GridPreview.module.scss create mode 100644 web/libs/datamanager/src/components/MainView/GridView/GridPreview.tsx create mode 100644 web/libs/datamanager/src/components/MainView/GridView/ImagePreview.tsx diff --git a/web/libs/datamanager/src/components/Common/Table/utils.js b/web/libs/datamanager/src/components/Common/Table/utils.js index d80bc8aab96e..592fbcccae4a 100644 --- a/web/libs/datamanager/src/components/Common/Table/utils.js +++ b/web/libs/datamanager/src/components/Common/Table/utils.js @@ -1,6 +1,7 @@ export const prepareColumns = (columns, hidden) => { + if (!hidden?.length) return columns; return columns.filter((col) => { - return !(hidden ?? []).includes(col.id); + return !hidden.includes(col.id); }); }; diff --git a/web/libs/datamanager/src/components/MainView/GridView/GridPreview.module.scss b/web/libs/datamanager/src/components/MainView/GridView/GridPreview.module.scss new file mode 100644 index 000000000000..5741df0a3c10 --- /dev/null +++ b/web/libs/datamanager/src/components/MainView/GridView/GridPreview.module.scss @@ -0,0 +1,62 @@ +.modal { + padding: 16px; + position: relative; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: content-box; + margin-bottom: 16px; +} + +.tooltip { + font-size: 12px; + max-width: 300px; + line-height: 16px; + + p { + margin-bottom: 4px; + } + + p:last-child { + margin-bottom: 0; + } +} + +.actions { + margin-left: auto; + + & > * { + width: 20px; + margin-left: 16px; + text-align: center; + cursor: pointer; + } +} + +.container { + overflow: hidden; + width: 100%; + height: 100%; + min-height: 100px; + display: flex; + position: relative; +} + +.container button { + padding: 0; + flex: 20px 0 0; + cursor: pointer; + background: none; + + &:hover { + background: var(--sand_200); + } +} + +.image { + pointer-events: none; + user-select: none; +} diff --git a/web/libs/datamanager/src/components/MainView/GridView/GridPreview.tsx b/web/libs/datamanager/src/components/MainView/GridView/GridPreview.tsx new file mode 100644 index 000000000000..a76c77245fed --- /dev/null +++ b/web/libs/datamanager/src/components/MainView/GridView/GridPreview.tsx @@ -0,0 +1,178 @@ +import { CloseOutlined, LeftCircleOutlined, QuestionCircleOutlined, RightCircleOutlined } from "@ant-design/icons"; +import { Checkbox } from "@humansignal/ui"; +import { observer } from "mobx-react"; +import type { PropsWithChildren } from "react"; +import { createContext, useCallback, useEffect, useRef, useState } from "react"; +import { modal } from "../../Common/Modal/Modal"; +import { Icon } from "../../Common/Icon/Icon"; +import { Tooltip } from "../../Common/Tooltip/Tooltip"; +import { ImagePreview } from "./ImagePreview"; + +import styles from "./GridPreview.module.scss"; + +type Task = { + id: number; + data: Record; +}; + +type GridViewContextType = { + tasks: Task[]; + imageField: string | undefined; + currentTaskId: number | null; + setCurrentTaskId: (id: number | null) => void; +}; + +type TaskModalProps = GridViewContextType & { view: any; imageField: string }; + +export const GridViewContext = createContext({ + tasks: [], + imageField: undefined, + currentTaskId: null, + setCurrentTaskId: () => {}, +}); + +const TaskModal = observer(({ view, tasks, imageField, currentTaskId, setCurrentTaskId }: TaskModalProps) => { + const index = tasks.findIndex((task) => task.id === currentTaskId); + const task = tasks[index]; + + const goToNext = useCallback(() => { + if (index < tasks.length - 1) { + setCurrentTaskId(tasks[index + 1].id); + } + }, [index, tasks]); + + const goToPrev = useCallback(() => { + if (index > 0) { + setCurrentTaskId(tasks[index - 1].id); + } + }, [index, tasks]); + + const onSelect = useCallback(() => { + if (task) { + view.toggleSelected(task.id); + } + }, [task, view]); + + const onClose = useCallback(() => { + setCurrentTaskId(null); + }, []); + + // assign hotkeys + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") { + goToPrev(); + } else if (event.key === "ArrowRight") { + goToNext(); + } else if (event.key === " ") { + onSelect(); + event.preventDefault(); + } else if (event.key === "Escape") { + onClose(); + } else { + // pass this event through for other keys + return; + } + + event.stopPropagation(); + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [goToNext, goToPrev, onSelect, onClose]); + + if (!task) { + return null; + } + + const tooltip = ( +
+

Preview of the task image to quickly navigate through the tasks and select the ones you want to work on.

+

Use [arrow keys] to navigate.

+

[Escape] to close the modal.

+

[Space] to select/unselect the task.

+
+ ); + + return ( +
+
+ + Task {task.id} + +
+ + + + +
+
+
+ + + +
+
+ ); +}); + +type GridViewProviderProps = PropsWithChildren<{ + data: Task[]; + view: any; + fields: { alias: string; currentType: string }[]; +}>; + +export const GridViewProvider: React.FC = ({ children, data, view, fields }) => { + const [currentTaskId, setCurrentTaskId] = useState(null); + const modalRef = useRef<{ update: (props: object) => void; close: () => void } | null>(null); + const imageField = fields.find((f) => f.currentType === "Image")?.alias; + + const onClose = useCallback(() => { + modalRef.current = null; + setCurrentTaskId(null); + }, []); + + useEffect(() => { + if (currentTaskId === null) { + modalRef.current?.close(); + return; + } + + if (!imageField) return; + + const children = ( + + ); + + if (!modalRef.current) { + modalRef.current = modal({ + bare: true, + title: "Task Preview", + style: { width: 800 }, + children, + onHidden: onClose, + }); + } else { + modalRef.current.update({ children }); + } + }, [currentTaskId, data, onClose]); + + // close the modal when we leave the view (by browser controls or by hotkeys) + useEffect(() => () => modalRef.current?.close(), []); + + return ( + + {children} + + ); +}; diff --git a/web/libs/datamanager/src/components/MainView/GridView/GridView.jsx b/web/libs/datamanager/src/components/MainView/GridView/GridView.jsx index 6f38220d1c8b..7fdbaca42d25 100644 --- a/web/libs/datamanager/src/components/MainView/GridView/GridView.jsx +++ b/web/libs/datamanager/src/components/MainView/GridView/GridView.jsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react"; -import React from "react"; +import { useCallback, useContext, useMemo } from "react"; import AutoSizer from "react-virtualized-auto-sizer"; import { FixedSizeGrid } from "react-window"; import InfiniteLoader from "react-window-infinite-loader"; @@ -8,9 +8,10 @@ import { Checkbox } from "@humansignal/ui"; import { Space } from "../../Common/Space/Space"; import { getProperty, prepareColumns } from "../../Common/Table/utils"; import * as DataGroups from "../../DataGroups"; -import "./GridView.scss"; -import { FF_LOPS_E_3, isFF } from "../../../utils/feature-flags"; +import { FF_GRID_PREVIEW, FF_LOPS_E_3, isFF } from "../../../utils/feature-flags"; import { SkeletonLoader } from "../../Common/SkeletonLoader"; +import { GridViewContext, GridViewProvider } from "./GridPreview"; +import "./GridView.scss"; const GridHeader = observer(({ row, selected }) => { const isSelected = selected.isSelected(row.id); @@ -60,11 +61,24 @@ const GridDataGroup = observer(({ type, value, field, row }) => { }); const GridCell = observer(({ view, selected, row, fields, onClick, ...props }) => { + const { setCurrentTaskId, imageField } = useContext(GridViewContext); + + const handleBodyClick = useCallback( + (e) => { + if (!isFF(FF_GRID_PREVIEW) || !imageField) return; + e.stopPropagation(); + setCurrentTaskId(row.id); + }, + [imageField, row.id], + ); + return ( - + + + ); @@ -75,7 +89,7 @@ export const GridView = observer(({ data, view, loadMore, fields, onChange, hidd const getCellIndex = (row, column) => columnCount * row + column; - const fieldsData = React.useMemo(() => { + const fieldsData = useMemo(() => { return prepareColumns(fields, hiddenFields); }, [fields, hiddenFields]); @@ -87,7 +101,7 @@ export const GridView = observer(({ data, view, loadMore, fields, onChange, hidd return res + height; }, 16); - const renderItem = React.useCallback( + const renderItem = useCallback( ({ style, rowIndex, columnIndex }) => { const index = getCellIndex(rowIndex, columnIndex); const row = data?.[index]; @@ -127,7 +141,7 @@ export const GridView = observer(({ data, view, loadMore, fields, onChange, hidd const itemCount = Math.ceil(data.length / columnCount); - const isItemLoaded = React.useCallback( + const isItemLoaded = useCallback( (index) => { const rowIndex = index * columnCount; const rowFullfilled = data.slice(rowIndex, columnCount).length === columnCount; @@ -138,37 +152,39 @@ export const GridView = observer(({ data, view, loadMore, fields, onChange, hidd ); return ( - - - {({ width, height }) => ( - - {({ onItemsRendered, ref }) => ( - - {renderItem} - - )} - - )} - - + + + + {({ width, height }) => ( + + {({ onItemsRendered, ref }) => ( + + {renderItem} + + )} + + )} + + + ); }); diff --git a/web/libs/datamanager/src/components/MainView/GridView/ImagePreview.tsx b/web/libs/datamanager/src/components/MainView/GridView/ImagePreview.tsx new file mode 100644 index 000000000000..6d9177fb5e04 --- /dev/null +++ b/web/libs/datamanager/src/components/MainView/GridView/ImagePreview.tsx @@ -0,0 +1,205 @@ +import { useState, useRef, useEffect, type CSSProperties } from "react"; +import { observer } from "mobx-react"; +import styles from "./GridPreview.module.scss"; + +const MAX_ZOOM = 20; +const ZOOM_FACTOR = 0.01; + +type Task = { + id: number; + data: Record; +}; + +type ImagePreviewProps = { + task: Task; + field: string; +}; + +// @todo constrain the position of the image to the container +const ImagePreview = observer(({ task, field }: ImagePreviewProps) => { + const src = task?.data?.[field ?? ""] ?? ""; + + const containerRef = useRef(null); + const imageRef = useRef(null); + + const [imageLoaded, setImageLoaded] = useState(false); + // visible container size + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + // scaled image size + const [imageSize, setImageSize] = useState({ width: 0, height: 0 }); + + // Zoom and position state + const [scale, setScale] = useState(1); + const [coverScale, setCoverScale] = useState(1); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + + const [isDragging, setIsDragging] = useState(false); + const [dragAnchor, setDragAnchor] = useState({ x: 0, y: 0 }); + const [startOffset, setStartOffset] = useState({ x: 0, y: 0 }); + + // Reset on task change + // biome-ignore lint/correctness/useExhaustiveDependencies: those are setStates, not values + useEffect(() => { + setScale(1); + setIsDragging(false); + }, [task, src]); + + const constrainOffset = (newOffset: { x: number; y: number }) => { + const { x, y } = newOffset; + const { width, height } = imageSize; + const { width: containerWidth, height: containerHeight } = containerSize; + + // to preserve paddings and make it less weird + const minX = (containerWidth - width) / 2; + const minY = (containerHeight - height) / 2; + // the far edges should be behind container edges + const maxX = Math.max(width * scale - containerWidth, 0); + const maxY = Math.max(height * scale - containerHeight, 0); + + return { + x: Math.min(Math.max(x, -maxX), minX), + y: Math.min(Math.max(y, -maxY), minY), + }; + }; + + const handleImageLoad = (e: React.SyntheticEvent) => { + if (containerRef.current) { + const img = e.currentTarget; + const containerRect = containerRef.current.getBoundingClientRect(); + + setContainerSize({ + width: containerRect.width, + height: containerRect.height, + }); + + const coverScaleX = containerRect.width / img.naturalWidth; + const coverScaleY = containerRect.height / img.naturalHeight; + // image is scaled by html, but we need to know this scale level + // how much is image zoomed out to fit into container + const imageScale = Math.min(coverScaleX, coverScaleY); + + const scaledWidth = img.naturalWidth * imageScale; + const scaledHeight = img.naturalHeight * imageScale; + // how much should we zoom image in to cover container + const coverScale = Math.max(containerRect.width / scaledWidth, containerRect.height / scaledHeight); + + setCoverScale(coverScale); + setImageSize({ + width: scaledWidth, + height: scaledHeight, + }); + + // Center the image initially + const initialX = (containerRect.width - scaledWidth) / 2; + const initialY = (containerRect.height - scaledHeight) / 2; + + setOffset({ x: initialX, y: initialY }); + setImageLoaded(true); + } + }; + + const handleWheel = (e: React.WheelEvent) => { + if (!containerRef.current || !imageLoaded) return; + + e.preventDefault(); + + const container = containerRef.current; + const rect = container.getBoundingClientRect(); + const img = imageRef.current; + if (!img) return; + + // Calculate cursor position relative to center + const cursorX = e.clientX - rect.left; + const cursorY = e.clientY - rect.top; + + // Zoom calculation + const newScale = + e.deltaY < 0 + ? Math.min(scale * (1 + ZOOM_FACTOR), MAX_ZOOM) // Max zoom + : Math.max(scale * (1 - ZOOM_FACTOR), 1); // Min zoom + + // Calculate zoom translation + const scaleDelta = newScale / scale; + // cursor - offset = cursor position relative to image; and that's the value being scaled. + // cursor position on a screen should stay the same, so we need to calculate new offset + // by scaling the distance to image edges and subtracting it from cursor position + const newX = cursorX - (cursorX - offset.x) * scaleDelta; + const newY = cursorY - (cursorY - offset.y) * scaleDelta; + + setScale(newScale); + setOffset(constrainOffset({ x: newX, y: newY })); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (!containerRef.current || scale <= 1) return; + + setIsDragging(true); + setDragAnchor({ x: e.clientX, y: e.clientY }); + setStartOffset({ x: offset.x, y: offset.y }); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging || !containerRef.current || !imageRef.current) return; + + const newX = e.clientX - dragAnchor.x; + const newY = e.clientY - dragAnchor.y; + + setOffset(constrainOffset({ x: startOffset.x + newX, y: startOffset.y + newY })); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + if (!task) return null; + + // Container styles + const containerStyle: CSSProperties = { + minHeight: "200px", + maxHeight: "calc(90vh - 120px)", + width: "100%", + position: "relative", + overflow: "hidden", + cursor: scale > 1 ? (isDragging ? "grabbing" : "grab") : "default", + }; + + // Image styles + const imageStyle: CSSProperties = imageLoaded + ? { + maxWidth: "100%", + maxHeight: "100%", + transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`, + transformOrigin: "0 0", + } + : { + width: "100%", + height: "100%", + objectFit: "contain", + }; + + return ( +
+ {src && ( + Task Preview + )} +
+ ); +}); + +export { ImagePreview }; diff --git a/web/libs/datamanager/src/utils/feature-flags.js b/web/libs/datamanager/src/utils/feature-flags.js index a3b1a84234ab..947bfa8ee4e2 100644 --- a/web/libs/datamanager/src/utils/feature-flags.js +++ b/web/libs/datamanager/src/utils/feature-flags.js @@ -68,6 +68,9 @@ export const FF_LOPS_86 = "fflag_feat_front_lops_86_datasets_storage_edit_short" */ export const FF_SELF_SERVE = "fflag_feat_front_leap_482_self_serve_short"; +/** Add ability to preview image tasks in Data Manager Grid View */ +export const FF_GRID_PREVIEW = "fflag_feat_front_leap_1424_grid_preview_short"; + export const FF_MEMORY_LEAK_FIX = "fflag_feat_all_optic_1178_reduce_memory_leak_short"; // Customize flags