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 {