Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: LEAP-1731: Bulk annotation follow-ups #6867

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const { Block, Elem } = BemWithSpecifiContext();

export class Modal extends React.Component {
modalRef = React.createRef();
originalOverflow = null;

constructor(props) {
super(props);
Expand All @@ -25,11 +26,18 @@ export class Modal extends React.Component {
}

componentDidMount() {
this.originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";

if (this.props.animateAppearance) {
setTimeout(() => this.show(), 30);
}
}

componentWillUnmount() {
document.body.style.overflow = this.originalOverflow;
}

setBody(body) {
this.setState({ body });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const TaskModal = observer(({ view, tasks, imageField, currentTaskId, setCurrent
<p>Use [arrow keys] to navigate.</p>
<p>[Escape] to close the modal.</p>
<p>[Space] to select/unselect the task.</p>
<p>Use [scroll] to zoom in/out and [drag] to pan around while image is zoomed in.</p>
</div>
);

Expand Down
263 changes: 150 additions & 113 deletions web/libs/datamanager/src/components/MainView/GridView/ImagePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useState, useRef, useEffect, type CSSProperties } from "react";
import { observer } from "mobx-react";
import React, { type CSSProperties } from "react";
import styles from "./GridPreview.module.scss";

const MAX_ZOOM = 20;
Expand All @@ -15,36 +14,49 @@ type ImagePreviewProps = {
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<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(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 });
interface ImagePreviewState {
offset: { x: number; y: number };
isDragging: boolean;
scale: number;
coverScale: number;
imageLoaded: boolean;
imageSize: { width: number; height: number };
containerSize: { width: number; height: number };
}

class ImagePreview extends React.Component<ImagePreviewProps, ImagePreviewState> {
containerRef = React.createRef<HTMLDivElement>();
imageRef = React.createRef<HTMLImageElement>();

// internal params for dragging state
dragAnchor = { x: 0, y: 0 };
startOffset = { x: 0, y: 0 };

constructor(props: ImagePreviewProps) {
super(props);
this.state = {
imageLoaded: false,
isDragging: false,
// Zoom and position state
scale: 1,
coverScale: 1,
offset: { x: 0, y: 0 },
// scaled image size
imageSize: { width: 0, height: 0 },
// visible container size
containerSize: { width: 0, height: 0 },
};
}

// Reset on task change
// biome-ignore lint/correctness/useExhaustiveDependencies: those are setStates, not values
useEffect(() => {
setScale(1);
setIsDragging(false);
}, [task, src]);
componentDidUpdate(prevProps: ImagePreviewProps) {
if (prevProps.task !== this.props.task || prevProps.field !== this.props.field) {
this.setState({ scale: 1, isDragging: false });
}
}

const constrainOffset = (newOffset: { x: number; y: number }) => {
constrainOffset = (newOffset: { x: number; y: number }) => {
const { scale, imageSize, containerSize } = this.state;
const { x, y } = newOffset;
const { width, height } = imageSize;
const { width: containerWidth, height: containerHeight } = containerSize;
Expand All @@ -62,15 +74,10 @@ const ImagePreview = observer(({ task, field }: ImagePreviewProps) => {
};
};

const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
if (containerRef.current) {
handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
if (this.containerRef.current) {
const img = e.currentTarget;
const containerRect = containerRef.current.getBoundingClientRect();

setContainerSize({
width: containerRect.width,
height: containerRect.height,
});
const containerRect = this.containerRef.current.getBoundingClientRect();

const coverScaleX = containerRect.width / img.naturalWidth;
const coverScaleY = containerRect.height / img.naturalHeight;
Expand All @@ -83,30 +90,34 @@ const ImagePreview = observer(({ task, field }: ImagePreviewProps) => {
// 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);
this.setState({
containerSize: {
width: containerRect.width,
height: containerRect.height,
},
coverScale,
imageSize: {
width: scaledWidth,
height: scaledHeight,
},
offset: { x: initialX, y: initialY },
imageLoaded: true,
});
}
};

const handleWheel = (e: React.WheelEvent) => {
if (!containerRef.current || !imageLoaded) return;
handleWheel = (e: React.WheelEvent) => {
const container = this.containerRef.current;
const img = this.imageRef.current;

e.preventDefault();
if (!container || !img || !this.state.imageLoaded) return;

const container = containerRef.current;
const rect = container.getBoundingClientRect();
const img = imageRef.current;
if (!img) return;
const { scale, offset } = this.state;

// Calculate cursor position relative to center
const cursorX = e.clientX - rect.left;
Expand All @@ -126,80 +137,106 @@ const ImagePreview = observer(({ task, field }: ImagePreviewProps) => {
const newX = cursorX - (cursorX - offset.x) * scaleDelta;
const newY = cursorY - (cursorY - offset.y) * scaleDelta;

setScale(newScale);
setOffset(constrainOffset({ x: newX, y: newY }));
this.setState({ scale: newScale, offset: this.constrainOffset({ x: newX, y: newY }) });
};

const handleMouseDown = (e: React.MouseEvent) => {
if (!containerRef.current || scale <= 1) return;
handleMouseDown = (e: React.MouseEvent) => {
if (!this.containerRef.current || this.state.scale <= 1) return;

setIsDragging(true);
setDragAnchor({ x: e.clientX, y: e.clientY });
setStartOffset({ x: offset.x, y: offset.y });
};
this.setState({ isDragging: true });
this.dragAnchor = { x: e.clientX, y: e.clientY };
this.startOffset = { ...this.state.offset };

const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging || !containerRef.current || !imageRef.current) return;
window.addEventListener("mousemove", this.handleMouseMove);
window.addEventListener("mouseup", this.handleMouseUp);
window.addEventListener("click", this.handleNoClickOutside, { capture: true, once: true });
};

const newX = e.clientX - dragAnchor.x;
const newY = e.clientY - dragAnchor.y;
componentWillUnmount() {
window.removeEventListener("mousemove", this.handleMouseMove);
window.removeEventListener("mouseup", this.handleMouseUp);
window.removeEventListener("click", this.handleNoClickOutside);
}

setOffset(constrainOffset({ x: startOffset.x + newX, y: startOffset.y + newY }));
// Prevent click outside from closing the modal while dragging
handleNoClickOutside = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};

const handleMouseUp = () => {
setIsDragging(false);
handleMouseMove = (e: MouseEvent) => {
if (!this.containerRef.current || !this.imageRef.current) return;

const { x: oldX, y: oldY } = this.dragAnchor;
const { x: offsetX, y: offsetY } = this.startOffset;
const newX = e.clientX - oldX;
const newY = e.clientY - oldY;

this.setState({ offset: this.constrainOffset({ x: offsetX + newX, y: offsetY + newY }) });
};

if (!task) return null;
handleMouseUp = () => {
this.setState({ isDragging: false });

// Container styles
const containerStyle: CSSProperties = {
minHeight: "200px",
maxHeight: "calc(90vh - 120px)",
width: "100%",
position: "relative",
overflow: "hidden",
cursor: scale > 1 ? (isDragging ? "grabbing" : "grab") : "default",
window.removeEventListener("mousemove", this.handleMouseMove);
window.removeEventListener("mouseup", this.handleMouseUp);
};

// 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 (
<div
ref={containerRef}
style={containerStyle}
className={styles.imageContainer}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{src && (
<img
ref={imageRef}
src={src}
alt="Task Preview"
style={imageStyle}
className={styles.image}
onLoad={handleImageLoad}
/>
)}
</div>
);
});
render() {
const src = this.props.task?.data?.[this.props.field ?? ""] ?? "";

if (!src) return null;

const { scale, offset, isDragging, imageLoaded, imageSize, containerSize } = this.state;

// Container styles
const containerStyle: CSSProperties = {
minHeight: "200px",
maxHeight: "calc(90vh - 120px)",
width: "100%",
position: "relative",
overflow: "hidden",
cursor: scale > 1 ? (isDragging ? "grabbing" : "grab") : "default",
userSelect: "none",
};

// 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 (
<div
ref={this.containerRef}
style={containerStyle}
className={styles.imageContainer}
// zoom on scroll
onWheel={this.handleWheel}
// start panning on drag
onMouseDown={this.handleMouseDown}
>
{src && (
<img
ref={this.imageRef}
src={src}
alt="Task Preview"
style={imageStyle}
className={styles.image}
onLoad={this.handleImageLoad}
/>
)}
</div>
);
}
}

export { ImagePreview };
Loading