From d20371b7d26af29baa88fe422091e0693ce6ca98 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Wed, 5 Feb 2020 17:58:52 +0300 Subject: [PATCH] Context menu using side panel --- cvat-canvas/src/typescript/canvasView.ts | 3 + cvat-ui/src/actions/annotation-actions.ts | 12 ++ .../canvas-context-menu.tsx | 31 +++ .../standard-workspace/canvas-wrapper.tsx | 22 +- .../standard-workspace/standard-workspace.tsx | 2 + .../standard-workspace/styles.scss | 13 ++ .../canvas-context-menu.tsx | 189 ++++++++++++++++++ .../standard-workspace/canvas-wrapper.tsx | 9 + cvat-ui/src/reducers/annotation-reducer.ts | 25 +++ cvat-ui/src/reducers/interfaces.ts | 10 + 10 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 cvat-ui/src/components/annotation-page/standard-workspace/canvas-context-menu.tsx create mode 100644 cvat-ui/src/containers/annotation-page/standard-workspace/canvas-context-menu.tsx diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 98efb21e993c..9465906ce7a2 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -445,6 +445,8 @@ export class CanvasViewImpl implements CanvasView, Listener { }); } } + + e.preventDefault(); } if (value) { @@ -618,6 +620,7 @@ export class CanvasViewImpl implements CanvasView, Listener { } else if (this.mode === Mode.ZOOM_CANVAS && event.which === 2) { self.controller.enableDrag(event.clientX, event.clientY); } + event.preventDefault(); } }); diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index a81b1db35c17..6f57ea46a1f1 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -73,6 +73,18 @@ export enum AnnotationActionTypes { UPLOAD_JOB_ANNOTATIONS_FAILED = 'UPLOAD_JOB_ANNOTATIONS_FAILED', REMOVE_JOB_ANNOTATIONS_SUCCESS = 'REMOVE_JOB_ANNOTATIONS_SUCCESS', REMOVE_JOB_ANNOTATIONS_FAILED = 'REMOVE_JOB_ANNOTATIONS_FAILED', + UPDATE_CANVAS_CONTEXT_MENU = 'UPDATE_CANVAS_CONTEXT_MENU', +} + +export function updateCanvasContextMenu(visible: boolean, left: number, top: number): AnyAction { + return { + type: AnnotationActionTypes.UPDATE_CANVAS_CONTEXT_MENU, + payload: { + visible, + left, + top, + }, + }; } export function removeAnnotationsAsync(sessionInstance: any): diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-context-menu.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-context-menu.tsx new file mode 100644 index 000000000000..7b6737dcdc9e --- /dev/null +++ b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-context-menu.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import ObjectItemContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/object-item'; + +interface Props { + activatedStateID: number | null; + visible: boolean; + left: number; + top: number; +} + +export default function CanvasContextMenu(props: Props): JSX.Element | null { + const { + activatedStateID, + visible, + left, + top, + } = props; + + if (!visible || activatedStateID === null) { + return null; + } + + return ReactDOM.createPortal( +
+ +
, + window.document.body, + ); +} diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx index 696ec2cd020a..6c9cc997d357 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx @@ -53,8 +53,9 @@ interface Props { onMergeAnnotations(sessionInstance: any, frame: number, states: any[]): void; onGroupAnnotations(sessionInstance: any, frame: number, states: any[]): void; onSplitAnnotations(sessionInstance: any, frame: number, state: any): void; - onActivateObject: (activatedStateID: number | null) => void; - onSelectObjects: (selectedStatesID: number[]) => void; + onActivateObject(activatedStateID: number | null): void; + onSelectObjects(selectedStatesID: number[]): void; + onUpdateContextMenu(visible: boolean, left: number, top: number): void; } export default class CanvasWrapperComponent extends React.PureComponent { @@ -322,6 +323,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { onZoomCanvas, onResetCanvas, onActivateObject, + onUpdateContextMenu, onEditShape, } = this.props; @@ -342,12 +344,24 @@ export default class CanvasWrapperComponent extends React.PureComponent { canvasInstance.grid(gridSize, gridSize); // Events - canvasInstance.html().addEventListener('click', (e: MouseEvent): void => { - if ((e.target as HTMLElement).tagName === 'svg') { + canvasInstance.html().addEventListener('mousedown', (e: MouseEvent): void => { + const { + activatedStateID, + } = this.props; + + if ((e.target as HTMLElement).tagName === 'svg' && activatedStateID !== null) { onActivateObject(null); } }); + canvasInstance.html().addEventListener('contextmenu', (e: MouseEvent): void => { + const { + activatedStateID, + } = this.props; + + onUpdateContextMenu(activatedStateID !== null, e.clientX, e.clientY); + }); + canvasInstance.html().addEventListener('canvas.editstart', (): void => { onActivateObject(null); onEditShape(true); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/standard-workspace.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/standard-workspace.tsx index 0951e5e9845f..3b33b7b5acaf 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/standard-workspace.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/standard-workspace.tsx @@ -9,6 +9,7 @@ import CanvasWrapperContainer from 'containers/annotation-page/standard-workspac import ControlsSideBarContainer from 'containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar'; import ObjectSideBarContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/objects-side-bar'; import PropagateConfirmContainer from 'containers/annotation-page/standard-workspace/propagate-confirm'; +import CanvasContextMenuContainer from 'containers/annotation-page/standard-workspace/canvas-context-menu'; export default function StandardWorkspaceComponent(): JSX.Element { return ( @@ -17,6 +18,7 @@ export default function StandardWorkspaceComponent(): JSX.Element { + ); } diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss b/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss index 0d9cae20e3b6..3a6e71fdb039 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss +++ b/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss @@ -110,4 +110,17 @@ width: 70px; margin: 0px 5px; } +} + +.cvat-canvas-context-menu { + opacity: 0.6; + position: fixed; + width: 300px; + z-index: 10; + max-height: 50%; + overflow-y: auto; + + &:hover { + opacity: 1; + } } \ No newline at end of file diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-context-menu.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-context-menu.tsx new file mode 100644 index 000000000000..9147a74f5ef3 --- /dev/null +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-context-menu.tsx @@ -0,0 +1,189 @@ +import React from 'react'; + +import { connect } from 'react-redux'; +import { CombinedState } from 'reducers/interfaces'; + +import CanvasContextMenuComponent from 'components/annotation-page/standard-workspace/canvas-context-menu'; + +interface StateToProps { + activatedStateID: number | null; + visible: boolean; + top: number; + left: number; + collapsed: boolean | undefined; +} + +function mapStateToProps(state: CombinedState): StateToProps { + const { + annotation: { + annotations: { + activatedStateID, + collapsed, + }, + canvas: { + contextMenu: { + visible, + top, + left, + }, + }, + }, + } = state; + + return { + activatedStateID, + collapsed: activatedStateID !== null ? collapsed[activatedStateID] : undefined, + visible, + left, + top, + }; +} + +type Props = StateToProps; + +interface State { + latestLeft: number; + latestTop: number; + left: number; + top: number; +} + +class CanvasContextMenuContainer extends React.PureComponent { + private initialized: HTMLDivElement | null; + private dragging: boolean; + private dragInitPosX: number; + private dragInitPosY: number; + public constructor(props: Props) { + super(props); + + this.initialized = null; + this.dragging = false; + this.dragInitPosX = 0; + this.dragInitPosY = 0; + this.state = { + latestLeft: 0, + latestTop: 0, + left: 0, + top: 0, + }; + } + + static getDerivedStateFromProps(props: Props, state: State): State | null { + if (props.left === state.latestLeft + && props.top === state.latestTop) { + return null; + } + + return { + ...state, + latestLeft: props.left, + latestTop: props.top, + top: props.top, + left: props.left, + }; + } + + public componentDidMount(): void { + this.updatePositionIfOutOfScreen(); + window.addEventListener('mousemove', this.moveContextMenu); + } + + public componentDidUpdate(prevProps: Props): void { + const { collapsed } = this.props; + + const [element] = window.document.getElementsByClassName('cvat-canvas-context-menu'); + if (collapsed !== prevProps.collapsed && element) { + element.addEventListener('transitionend', () => { + this.updatePositionIfOutOfScreen(); + }, { once: true }); + } else if (element) { + this.updatePositionIfOutOfScreen(); + } + + if (element && (!this.initialized || this.initialized !== element)) { + this.initialized = element as HTMLDivElement; + + this.initialized.addEventListener('mousedown', (e: MouseEvent): any => { + this.dragging = true; + this.dragInitPosX = e.clientX; + this.dragInitPosY = e.clientY; + }); + + this.initialized.addEventListener('mouseup', () => { + this.dragging = false; + }); + } + } + + public componentWillUnmount(): void { + window.removeEventListener('mousemove', this.moveContextMenu); + } + + private moveContextMenu = (e: MouseEvent): void => { + if (this.dragging) { + this.setState((state) => { + const value = { + left: state.left + e.clientX - this.dragInitPosX, + top: state.top + e.clientY - this.dragInitPosY, + }; + + this.dragInitPosX = e.clientX; + this.dragInitPosY = e.clientY; + + return value; + }); + + e.preventDefault(); + } + }; + + private updatePositionIfOutOfScreen(): void { + const { + top, + left, + } = this.state; + + const { + innerWidth, + innerHeight, + } = window; + + const [element] = window.document.getElementsByClassName('cvat-canvas-context-menu'); + if (element) { + const height = element.clientHeight; + const width = element.clientWidth; + + if (top + height > innerHeight || left + width > innerWidth) { + this.setState({ + top: top - Math.max(top + height - innerHeight, 0), + left: left - Math.max(left + width - innerWidth, 0), + }); + } + } + } + + public render(): JSX.Element { + const { + left, + top, + } = this.state; + + const { + visible, + activatedStateID, + } = this.props; + + return ( + + ); + } +} + +export default connect( + mapStateToProps, +)(CanvasContextMenuContainer); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx index ff1e81a92e7e..5489e40aa516 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx @@ -20,6 +20,7 @@ import { splitAnnotationsAsync, activateObject, selectObjects, + updateCanvasContextMenu, } from 'actions/annotation-actions'; import { ColorBy, @@ -68,6 +69,7 @@ interface DispatchToProps { onSplitAnnotations(sessionInstance: any, frame: number, state: any): void; onActivateObject: (activatedStateID: number | null) => void; onSelectObjects: (selectedStatesID: number[]) => void; + onUpdateContextMenu(visible: boolean, left: number, top: number): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -179,11 +181,18 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { dispatch(splitAnnotationsAsync(sessionInstance, frame, state)); }, onActivateObject(activatedStateID: number | null): void { + if (activatedStateID === null) { + dispatch(updateCanvasContextMenu(false, 0, 0)); + } + dispatch(activateObject(activatedStateID)); }, onSelectObjects(selectedStatesID: number[]): void { dispatch(selectObjects(selectedStatesID)); }, + onUpdateContextMenu(visible: boolean, left: number, top: number): void { + dispatch(updateCanvasContextMenu(visible, left, top)); + }, }; } diff --git a/cvat-ui/src/reducers/annotation-reducer.ts b/cvat-ui/src/reducers/annotation-reducer.ts index f5b81f62f5b2..c0800d1ce248 100644 --- a/cvat-ui/src/reducers/annotation-reducer.ts +++ b/cvat-ui/src/reducers/annotation-reducer.ts @@ -15,6 +15,11 @@ const defaultState: AnnotationState = { loads: {}, }, canvas: { + contextMenu: { + visible: false, + left: 0, + top: 0, + }, instance: new Canvas(), ready: false, activeControl: ActiveControl.CURSOR, @@ -766,6 +771,26 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } + case AnnotationActionTypes.UPDATE_CANVAS_CONTEXT_MENU: { + const { + visible, + left, + top, + } = action.payload; + + return { + ...state, + canvas: { + ...state.canvas, + contextMenu: { + ...state.canvas.contextMenu, + visible, + left, + top, + }, + }, + }; + } case AnnotationActionTypes.RESET_CANVAS: { return { ...state, diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index 9ab3749e9fd5..9305ca020212 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -258,6 +258,11 @@ export enum StatesOrdering { UPDATED = 'Updated time', } +export enum ContextMenuType { + CANVAS = 'canvas', + CANVAS_SHAPE = 'canvas_shape', +} + export interface AnnotationState { activities: { loads: { @@ -266,6 +271,11 @@ export interface AnnotationState { }; }; canvas: { + contextMenu: { + visible: boolean; + top: number; + left: number; + }; instance: Canvas; ready: boolean; activeControl: ActiveControl;