diff --git a/cvat-ui/src/components/annotation-page/canvas/grid-layout/canvas-layout.tsx b/cvat-ui/src/components/annotation-page/canvas/grid-layout/canvas-layout.tsx
index 4c19933ab263..d9ab94092c38 100644
--- a/cvat-ui/src/components/annotation-page/canvas/grid-layout/canvas-layout.tsx
+++ b/cvat-ui/src/components/annotation-page/canvas/grid-layout/canvas-layout.tsx
@@ -37,7 +37,7 @@ import defaultLayout, { ItemLayout, ViewType } from './canvas-layout.conf';
const ReactGridLayout = WidthProvider(RGL);
-const ViewFabric = (itemLayout: ItemLayout): JSX.Element => {
+const ViewFabric = (itemLayout: ItemLayout, fullscreenKey?: string): JSX.Element => {
const { viewType: type, offset } = itemLayout;
let component = null;
@@ -49,7 +49,7 @@ const ViewFabric = (itemLayout: ItemLayout): JSX.Element => {
component = ;
break;
case ViewType.RELATED_IMAGE:
- component = ;
+ component = ;
break;
case ViewType.CANVAS_3D_FRONT:
component = ;
@@ -201,7 +201,7 @@ function CanvasLayout({ type }: { type?: DimensionType }): JSX.Element {
window.dispatchEvent(new Event('resize'));
}, [layoutConfig]);
- const children = layoutConfig.map((value: ItemLayout) => ViewFabric(value));
+ const children = layoutConfig.map((value: ItemLayout) => ViewFabric(value, fullscreenKey));
const layout = layoutConfig.map((value: ItemLayout) => ({
x: value.x,
y: value.y,
diff --git a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx
index 322a345efea7..a8ef8712a533 100644
--- a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx
+++ b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx
@@ -591,7 +591,7 @@ class CanvasWrapperComponent extends React.PureComponent {
public componentWillUnmount(): void {
const { canvasInstance } = this.props as { canvasInstance: Canvas };
-
+ canvasInstance.html().removeEventListener('mousemove', this.onCanvasMouseMove);
canvasInstance.html().removeEventListener('mousedown', this.onCanvasMouseDown);
canvasInstance.html().removeEventListener('click', this.onCanvasClicked);
canvasInstance.html().removeEventListener('canvas.editstart', this.onCanvasEditStart);
@@ -746,6 +746,25 @@ class CanvasWrapperComponent extends React.PureComponent {
onStartIssue(points);
};
+ private onCanvasMouseMove = (e: MouseEvent): void => {
+ if (e.buttons === 2) {
+ const {
+ onChangeBrightnessLevel,
+ onChangeContrastLevel,
+ brightnessLevel,
+ contrastLevel,
+ } = this.props;
+
+ const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
+
+ const newBrightness = clamp((brightnessLevel * 100) + (e.movementX / 2), 50, 200);
+ const newContrast = clamp((contrastLevel * 100) + (e.movementY / 2), 50, 200);
+
+ onChangeBrightnessLevel(newBrightness);
+ onChangeContrastLevel(newContrast);
+ }
+ };
+
private onCanvasMouseDown = (e: MouseEvent): void => {
const { workspace, activatedStateID, onActivateObject } = this.props;
@@ -1056,6 +1075,7 @@ class CanvasWrapperComponent extends React.PureComponent {
{ once: true },
);
+ canvasInstance.html().addEventListener('mousemove', this.onCanvasMouseMove);
canvasInstance.html().addEventListener('mousedown', this.onCanvasMouseDown);
canvasInstance.html().addEventListener('click', this.onCanvasClicked);
canvasInstance.html().addEventListener('canvas.editstart', this.onCanvasEditStart);
diff --git a/cvat-ui/src/components/annotation-page/canvas/views/context-image/context-image.tsx b/cvat-ui/src/components/annotation-page/canvas/views/context-image/context-image.tsx
index f21e646b931f..5e3f8507cdb6 100644
--- a/cvat-ui/src/components/annotation-page/canvas/views/context-image/context-image.tsx
+++ b/cvat-ui/src/components/annotation-page/canvas/views/context-image/context-image.tsx
@@ -9,18 +9,20 @@ import PropTypes from 'prop-types';
import notification from 'antd/lib/notification';
import Spin from 'antd/lib/spin';
import Text from 'antd/lib/typography/Text';
-import { SettingOutlined } from '@ant-design/icons';
+import { ReloadOutlined, SettingOutlined } from '@ant-design/icons';
import CVATTooltop from 'components/common/cvat-tooltip';
import { CombinedState } from 'reducers';
import ContextImageSelector from './context-image-selector';
+import { useCanvasControl } from './model';
interface Props {
offset: number[];
+ fullscreenKey?: string;
}
function ContextImage(props: Props): JSX.Element {
- const { offset } = props;
+ const { offset, fullscreenKey } = props;
const defaultFrameOffset = (offset[0] || 0);
const defaultContextImageOffset = (offset[1] || 0);
@@ -38,6 +40,18 @@ function ContextImage(props: Props): JSX.Element {
const [hasError, setHasError] = useState(false);
const [showSelector, setShowSelector] = useState(false);
+ const {
+ canvasStyle,
+ handleContextMenu,
+ handleMouseDown,
+ handleMouseLeave,
+ handleMouseMove,
+ handleMouseUp,
+ handleZoomChange,
+ resetColorSetting,
+ wrapperRef,
+ } = useCanvasControl(canvasRef, fullscreenKey);
+
useEffect(() => {
let unmounted = false;
const promise = job.frames.contextImage(frameIndex);
@@ -82,7 +96,12 @@ function ContextImage(props: Props): JSX.Element {
const contextImageName = Object.keys(contextImageData).sort()[contextImageOffset];
return (
-
+
{ relatedFiles > 1 && (
)}
+
{contextImageName}
@@ -102,8 +125,18 @@ function ContextImage(props: Props): JSX.Element {
(!fetching && contextImageOffset >= Object.keys(contextImageData).length)) && No data }
{ fetching && }
{
- contextImageOffset < Object.keys(contextImageData).length &&
-
+ contextImageOffset < Object.keys(contextImageData).length && (
+
+
+
+ )
}
{ showSelector && (
, fullscreenKey: string | undefined) => {
+ const [zoomLevel, setZoomLevel] = useState(1);
+
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragOffset, setDragOffset] = useState<{ x: number; y: number }>({
+ x: 0,
+ y: 0,
+ });
+ const positionRef = useRef({ x: 0, y: 0 });
+ const animationFrameRef = useRef(null);
+ const wrapperRef = useRef(null);
+ const [brightness, setBrightness] = useState(100);
+ const [contrast, setContrast] = useState(100);
+
+ useEffect(() => {
+ positionRef.current = { x: 0, y: 0 };
+ if (canvasRef.current) {
+ canvasRef.current.style.transform = `scale(${zoomLevel}) translate(0px, 0px)`;
+ }
+ }, [fullscreenKey]);
+
+ const resetColorSetting = () => {
+ setBrightness(100);
+ setContrast(100);
+ };
+ const handleZoomChange = useCallback(
+ (event: React.WheelEvent) => {
+ const delta = event.deltaY;
+ const newZoomLevel = zoomLevel - delta * 0.001;
+ setZoomLevel(Math.max(0.5, Math.min(5, newZoomLevel)));
+ },
+ [zoomLevel],
+ );
+
+ const handleMouseMove = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.buttons === 1 && isDragging && canvasRef.current) {
+ const newPosition = {
+ x: (e.clientX - dragOffset.x) / zoomLevel,
+ y: (e.clientY - dragOffset.y) / zoomLevel,
+ };
+ positionRef.current = newPosition;
+ if (animationFrameRef.current !== null) {
+ cancelAnimationFrame(animationFrameRef.current);
+ }
+ animationFrameRef.current = requestAnimationFrame(() => {
+ if (canvasRef.current) {
+ canvasRef.current.style.transform = `
+ scale(${zoomLevel}) translate(${positionRef.current.x}px, ${positionRef.current.y}px)`;
+ }
+ animationFrameRef.current = null;
+ });
+ } else if (e.buttons === 2) {
+ const deltaX = e.movementX;
+ const deltaY = e.movementY;
+
+ const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
+
+ if (deltaY !== 0) {
+ setContrast((prevContrast) => clamp(prevContrast + deltaY / 2, 50, 200));
+ }
+
+ if (deltaX !== 0) {
+ setBrightness((prevBrightness) => clamp(prevBrightness + deltaX / 2, 50, 200));
+ }
+ }
+ }, [zoomLevel, isDragging, dragOffset]);
+
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.button === 0) {
+ setIsDragging(true);
+ setDragOffset({
+ x: e.clientX - positionRef.current.x * zoomLevel,
+ y: e.clientY - positionRef.current.y * zoomLevel,
+ });
+ if (canvasRef.current) {
+ canvasRef.current.style.cursor = 'grabbing';
+ }
+ }
+ },
+ [zoomLevel],
+ );
+
+ const canvasStyle = useMemo(
+ () => ({
+ transform: `scale(${zoomLevel}) translate(${positionRef.current.x}px, ${positionRef.current.y}px)`,
+ transformOrigin: 'center',
+ cursor: isDragging ? 'grabbing' : 'grab',
+ transition: isDragging ? 'none' : 'transform 0.1s ease-out',
+ filter: `brightness(${brightness}%) contrast(${contrast}%)`,
+ }),
+ [zoomLevel, isDragging, brightness, contrast],
+ );
+
+ const handleMouseUp = useCallback(() => {
+ setIsDragging(false);
+ if (canvasRef.current) {
+ canvasRef.current.style.cursor = 'grab';
+ }
+ if (animationFrameRef.current !== null) {
+ cancelAnimationFrame(animationFrameRef.current);
+ animationFrameRef.current = null;
+ }
+ }, []);
+
+ const handleMouseLeave = useCallback(() => {
+ // setIsDragging(false);
+ // if (canvasRef.current) {
+ // canvasRef.current.style.cursor = 'grab';
+ // }
+ }, []);
+
+ const handleContextMenu = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ }, []);
+
+ return {
+ handleContextMenu,
+ handleZoomChange,
+ handleMouseLeave,
+ handleMouseUp,
+ wrapperRef,
+ canvasStyle,
+ handleMouseDown,
+ handleMouseMove,
+ resetColorSetting,
+ };
+};
diff --git a/cvat-ui/src/components/annotation-page/canvas/views/context-image/model/index.ts b/cvat-ui/src/components/annotation-page/canvas/views/context-image/model/index.ts
new file mode 100644
index 000000000000..f365921f4435
--- /dev/null
+++ b/cvat-ui/src/components/annotation-page/canvas/views/context-image/model/index.ts
@@ -0,0 +1,5 @@
+// Copyright (C) 2024 CVAT.ai Corporation
+//
+// SPDX-License-Identifier: MIT
+
+export { useCanvasControl } from './hooks';
diff --git a/cvat-ui/src/components/annotation-page/canvas/views/context-image/styles.scss b/cvat-ui/src/components/annotation-page/canvas/views/context-image/styles.scss
index 441388bb18fa..3fb51d847452 100644
--- a/cvat-ui/src/components/annotation-page/canvas/views/context-image/styles.scss
+++ b/cvat-ui/src/components/annotation-page/canvas/views/context-image/styles.scss
@@ -9,6 +9,8 @@
height: 100%;
display: flex;
justify-content: center;
+ overflow: hidden;
+ border-radius: 10px 10px 0 0;
> .ant-spin {
position: absolute;
@@ -60,15 +62,33 @@
top: $grid-unit-size;
right: $grid-unit-size;
}
+
+ > .cvat-context-image-reset-button{
+ position: absolute;
+ opacity: 0.6;
+ top: $grid-unit-size * 1.1;
+ left: $grid-unit-size * 7;
+
+ &:hover{
+ opacity: 1;
+ }
+ }
}
- > canvas {
- object-fit: contain;
+ .draggable-wrapper{
position: relative;
- top: calc(50% + $grid-unit-size * 2);
- transform: translateY(-50%);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ overflow: hidden;
width: 100%;
- height: calc(100% - $grid-unit-size * 4);
+
+ > canvas {
+ object-fit: contain;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ }
}
&:hover {