Skip to content

Commit

Permalink
feat: LEAP-1424: LEAP-1370: Allow to zoom and pan the image in previe…
Browse files Browse the repository at this point in the history
…w modal (#6781)

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 #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:
- `<Arrows left/right>` to switch between tasks
- `<Space>` to select/unselect task
- `<Escape>` to close the modal

Co-authored-by: bmartel <[email protected]>
Co-authored-by: MihajloHoma <[email protected]>
Co-authored-by: hlomzik <[email protected]>
  • Loading branch information
4 people authored Dec 18, 2024
1 parent e4b519a commit e5d54c7
Show file tree
Hide file tree
Showing 6 changed files with 505 additions and 40 deletions.
3 changes: 2 additions & 1 deletion web/libs/datamanager/src/components/Common/Table/utils.js
Original file line number Diff line number Diff line change
@@ -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);
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
178 changes: 178 additions & 0 deletions web/libs/datamanager/src/components/MainView/GridView/GridPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};

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<GridViewContextType>({
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 = (
<div className={styles.tooltip}>
<p>Preview of the task image to quickly navigate through the tasks and select the ones you want to work on.</p>
<p>Use [arrow keys] to navigate.</p>
<p>[Escape] to close the modal.</p>
<p>[Space] to select/unselect the task.</p>
</div>
);

return (
<div className={styles.modal}>
<div className={styles.header}>
<Checkbox checked={view.selected.isSelected(task.id)} onChange={onSelect}>
Task {task.id}
</Checkbox>
<div className={styles.actions}>
<Tooltip title={tooltip}>
<Icon icon={QuestionCircleOutlined} />
</Tooltip>
<Icon icon={CloseOutlined} onClick={onClose} />
</div>
</div>
<div className={styles.container}>
<button type="button" onClick={goToPrev} disabled={index === 0}>
<Icon icon={LeftCircleOutlined} />
</button>
<ImagePreview task={task} field={imageField} />
<button type="button" onClick={goToNext} disabled={index === tasks.length - 1}>
<Icon icon={RightCircleOutlined} />
</button>
</div>
</div>
);
});

type GridViewProviderProps = PropsWithChildren<{
data: Task[];
view: any;
fields: { alias: string; currentType: string }[];
}>;

export const GridViewProvider: React.FC<GridViewProviderProps> = ({ children, data, view, fields }) => {
const [currentTaskId, setCurrentTaskId] = useState<number | null>(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 = (
<TaskModal
view={view}
tasks={data}
imageField={imageField}
currentTaskId={currentTaskId}
setCurrentTaskId={setCurrentTaskId}
/>
);

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 (
<GridViewContext.Provider value={{ tasks: data, imageField, currentTaskId, setCurrentTaskId }}>
{children}
</GridViewContext.Provider>
);
};
94 changes: 55 additions & 39 deletions web/libs/datamanager/src/components/MainView/GridView/GridView.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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 (
<Elem {...props} name="cell" onClick={onClick} mod={{ selected: selected.isSelected(row.id) }}>
<Elem name="cell-content">
<GridHeader view={view} row={row} fields={fields} selected={view.selected} />
<GridBody view={view} row={row} fields={fields} />
<Elem name="cell-body" onClick={handleBodyClick}>
<GridBody view={view} row={row} fields={fields} />
</Elem>
</Elem>
</Elem>
);
Expand All @@ -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]);

Expand All @@ -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];
Expand Down Expand Up @@ -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;
Expand All @@ -138,37 +152,39 @@ export const GridView = observer(({ data, view, loadMore, fields, onChange, hidd
);

return (
<Block name="grid-view" mod={{ columnCount }}>
<Elem tag={AutoSizer} name="resize">
{({ width, height }) => (
<InfiniteLoader
itemCount={itemCount}
isItemLoaded={isItemLoaded}
loadMoreItems={loadMore}
threshold={Math.floor(view.dataStore.pageSize / 2)}
minimumBatchSize={view.dataStore.pageSize}
>
{({ onItemsRendered, ref }) => (
<Elem
tag={FixedSizeGrid}
ref={ref}
width={width}
height={height}
name="list"
rowHeight={rowHeight + 42}
overscanRowCount={view.dataStore.pageSize}
columnCount={columnCount}
columnWidth={width / columnCount - 9.5}
rowCount={itemCount}
onItemsRendered={onItemsRenderedWrap(onItemsRendered)}
style={{ overflowX: "hidden" }}
>
{renderItem}
</Elem>
)}
</InfiniteLoader>
)}
</Elem>
</Block>
<GridViewProvider data={data} view={view} fields={fieldsData}>
<Block name="grid-view" mod={{ columnCount }}>
<Elem tag={AutoSizer} name="resize">
{({ width, height }) => (
<InfiniteLoader
itemCount={itemCount}
isItemLoaded={isItemLoaded}
loadMoreItems={loadMore}
threshold={Math.floor(view.dataStore.pageSize / 2)}
minimumBatchSize={view.dataStore.pageSize}
>
{({ onItemsRendered, ref }) => (
<Elem
tag={FixedSizeGrid}
ref={ref}
width={width}
height={height}
name="list"
rowHeight={rowHeight + 42}
overscanRowCount={view.dataStore.pageSize}
columnCount={columnCount}
columnWidth={width / columnCount - 9.5}
rowCount={itemCount}
onItemsRendered={onItemsRenderedWrap(onItemsRendered)}
style={{ overflowX: "hidden" }}
>
{renderItem}
</Elem>
)}
</InfiniteLoader>
)}
</Elem>
</Block>
</GridViewProvider>
);
});
Loading

0 comments on commit e5d54c7

Please sign in to comment.