From 939de868a9377575da42cae8224ca28e7387a1d5 Mon Sep 17 00:00:00 2001 From: Boris Sekachev <40690378+bsekachev@users.noreply.github.com> Date: Thu, 6 Feb 2020 18:17:19 +0300 Subject: [PATCH] React UI: Added annotation menus, added shape context menu, added some confirmations before dangerous actions (#1123) * Annotation menu, modified tasks menu * Removed extra styles * Context menu using side panel * Mousewheel on draw * Added more cursor icons * Do not check .svg & .scss by eslint --- cvat-canvas/src/typescript/canvasView.ts | 17 +- cvat-canvas/src/typescript/splitHandler.ts | 11 +- cvat-ui/.eslintrc.js | 1 + cvat-ui/src/actions/annotation-actions.ts | 99 +++++++++ cvat-ui/src/actions/tasks-actions.ts | 11 +- .../components/actions-menu/actions-menu.tsx | 194 +++++++++-------- .../components/actions-menu/dump-submenu.tsx | 51 +++++ .../components/actions-menu/dumper-item.tsx | 50 ----- .../components/actions-menu/export-item.tsx | 45 ---- .../actions-menu/export-submenu.tsx | 43 ++++ .../components/actions-menu/load-submenu.tsx | 61 ++++++ .../components/actions-menu/loader-item.tsx | 56 ----- .../src/components/actions-menu/styles.scss | 32 ++- .../canvas-context-menu.tsx | 31 +++ .../standard-workspace/canvas-wrapper.tsx | 22 +- .../standard-workspace/standard-workspace.tsx | 2 + .../standard-workspace/styles.scss | 13 ++ .../components/annotation-page/styles.scss | 15 +- .../top-bar/annotation-menu.tsx | 125 +++++++++++ .../annotation-page/top-bar/left-group.tsx | 13 +- .../annotation-page/top-bar/menu.tsx | 0 .../containers/actions-menu/actions-menu.tsx | 161 +++++++++++--- .../canvas-context-menu.tsx | 189 +++++++++++++++++ .../standard-workspace/canvas-wrapper.tsx | 9 + .../top-bar/annotation-menu.tsx | 166 +++++++++++++++ .../src/containers/task-page/task-page.tsx | 4 +- .../src/containers/tasks-page/task-item.tsx | 2 +- cvat-ui/src/reducers/annotation-reducer.ts | 97 +++++++++ cvat-ui/src/reducers/interfaces.ts | 40 ++-- cvat-ui/src/reducers/notifications-reducer.ts | 45 ++++ cvat-ui/src/reducers/tasks-reducer.ts | 199 +++++------------- 31 files changed, 1341 insertions(+), 463 deletions(-) create mode 100644 cvat-ui/src/components/actions-menu/dump-submenu.tsx delete mode 100644 cvat-ui/src/components/actions-menu/dumper-item.tsx delete mode 100644 cvat-ui/src/components/actions-menu/export-item.tsx create mode 100644 cvat-ui/src/components/actions-menu/export-submenu.tsx create mode 100644 cvat-ui/src/components/actions-menu/load-submenu.tsx delete mode 100644 cvat-ui/src/components/actions-menu/loader-item.tsx create mode 100644 cvat-ui/src/components/annotation-page/standard-workspace/canvas-context-menu.tsx create mode 100644 cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx delete mode 100644 cvat-ui/src/components/annotation-page/top-bar/menu.tsx create mode 100644 cvat-ui/src/containers/annotation-page/standard-workspace/canvas-context-menu.tsx create mode 100644 cvat-ui/src/containers/annotation-page/top-bar/annotation-menu.tsx diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 98efb21e993c..9cd354a9b5d8 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) { @@ -615,9 +617,10 @@ export class CanvasViewImpl implements CanvasView, Listener { if ([1, 2].includes(event.which)) { if ([Mode.DRAG_CANVAS, Mode.IDLE].includes(this.mode)) { self.controller.enableDrag(event.clientX, event.clientY); - } else if (this.mode === Mode.ZOOM_CANVAS && event.which === 2) { + } else if ([Mode.ZOOM_CANVAS, Mode.DRAW].includes(this.mode) && event.which === 2) { self.controller.enableDrag(event.clientX, event.clientY); } + event.preventDefault(); } }); @@ -751,25 +754,37 @@ export class CanvasViewImpl implements CanvasView, Listener { } else if (reason === UpdateReasons.DRAW) { const data: DrawData = this.controller.drawData; if (data.enabled) { + this.canvas.style.cursor = 'crosshair'; this.mode = Mode.DRAW; + } else { + this.canvas.style.cursor = ''; } this.drawHandler.draw(data, this.geometry); } else if (reason === UpdateReasons.MERGE) { const data: MergeData = this.controller.mergeData; if (data.enabled) { + this.canvas.style.cursor = 'copy'; this.mode = Mode.MERGE; + } else { + this.canvas.style.cursor = ''; } this.mergeHandler.merge(data); } else if (reason === UpdateReasons.SPLIT) { const data: SplitData = this.controller.splitData; if (data.enabled) { + this.canvas.style.cursor = 'copy'; this.mode = Mode.SPLIT; + } else { + this.canvas.style.cursor = ''; } this.splitHandler.split(data); } else if (reason === UpdateReasons.GROUP) { const data: GroupData = this.controller.groupData; if (data.enabled) { + this.canvas.style.cursor = 'copy'; this.mode = Mode.GROUP; + } else { + this.canvas.style.cursor = ''; } this.groupHandler.group(data); } else if (reason === UpdateReasons.SELECT) { diff --git a/cvat-canvas/src/typescript/splitHandler.ts b/cvat-canvas/src/typescript/splitHandler.ts index 46e5b646fa49..6a0b8a5c4dea 100644 --- a/cvat-canvas/src/typescript/splitHandler.ts +++ b/cvat-canvas/src/typescript/splitHandler.ts @@ -27,13 +27,13 @@ export class SplitHandlerImpl implements SplitHandler { private release(): void { if (this.initialized) { this.resetShape(); - this.canvas.node.removeEventListener('mousemove', this.onFindObject); + this.canvas.node.removeEventListener('mousemove', this.findObject); this.initialized = false; } } private initSplitting(): void { - this.canvas.node.addEventListener('mousemove', this.onFindObject); + this.canvas.node.addEventListener('mousemove', this.findObject); this.initialized = true; this.splitDone = false; } @@ -47,6 +47,11 @@ export class SplitHandlerImpl implements SplitHandler { this.release(); } + private findObject = (e: MouseEvent): void => { + this.resetShape(); + this.onFindObject(e); + }; + public constructor( onSplitDone: (object: any) => void, onFindObject: (event: MouseEvent) => void, @@ -83,8 +88,6 @@ export class SplitHandlerImpl implements SplitHandler { once: true, }); } - } else { - this.resetShape(); } } diff --git a/cvat-ui/.eslintrc.js b/cvat-ui/.eslintrc.js index 279f581cca09..51e84de5076c 100644 --- a/cvat-ui/.eslintrc.js +++ b/cvat-ui/.eslintrc.js @@ -17,6 +17,7 @@ module.exports = { '@typescript-eslint', 'import', ], + 'ignorePatterns': ['*.svg', '*.scss'], 'extends': [ 'plugin:@typescript-eslint/recommended', 'airbnb-typescript', diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 0bf029ba7cc9..6f57ea46a1f1 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -68,6 +68,105 @@ export enum AnnotationActionTypes { CHANGE_JOB_STATUS = 'CHANGE_JOB_STATUS', CHANGE_JOB_STATUS_SUCCESS = 'CHANGE_JOB_STATUS_SUCCESS', CHANGE_JOB_STATUS_FAILED = 'CHANGE_JOB_STATUS_FAILED', + UPLOAD_JOB_ANNOTATIONS = 'UPLOAD_JOB_ANNOTATIONS', + UPLOAD_JOB_ANNOTATIONS_SUCCESS = 'UPLOAD_JOB_ANNOTATIONS_SUCCESS', + 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): +ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + try { + sessionInstance.annotations.clear(); + dispatch({ + type: AnnotationActionTypes.REMOVE_JOB_ANNOTATIONS_SUCCESS, + payload: { + sessionInstance, + }, + }); + } catch (error) { + dispatch({ + type: AnnotationActionTypes.REMOVE_JOB_ANNOTATIONS_FAILED, + payload: { + error, + }, + }); + } + }; +} + + +export function uploadJobAnnotationsAsync(job: any, loader: any, file: File): +ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + try { + const store = getCVATStore(); + const state: CombinedState = store.getState(); + if (state.tasks.activities.loads[job.task.id]) { + throw Error('Annotations is being uploaded for the task'); + } + if (state.annotation.activities.loads[job.id]) { + throw Error('Only one uploading of annotations for a job allowed at the same time'); + } + + dispatch({ + type: AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS, + payload: { + job, + loader, + }, + }); + + const frame = state.annotation.player.frame.number; + await job.annotations.upload(file, loader); + + // One more update to escape some problems + // in canvas when shape with the same + // clientID has different type (polygon, rectangle) for example + dispatch({ + type: AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_SUCCESS, + payload: { + job, + states: [], + }, + }); + + await job.annotations.clear(true); + const states = await job.annotations.get(frame); + + setTimeout(() => { + dispatch({ + type: AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_SUCCESS, + payload: { + job, + states, + }, + }); + }); + } catch (error) { + dispatch({ + type: AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_FAILED, + payload: { + job, + error, + }, + }); + } + }; } export function changeJobStatusAsync(jobInstance: any, status: string): diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index fa9c3a5851f2..6f7f1a52e485 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -1,6 +1,10 @@ import { AnyAction, Dispatch, ActionCreator } from 'redux'; import { ThunkAction } from 'redux-thunk'; -import { TasksQuery } from 'reducers/interfaces'; +import { + TasksQuery, + CombinedState, +} from 'reducers/interfaces'; +import { getCVATStore } from 'cvat-store'; import getCore from 'cvat-core'; import { getInferenceStatusAsync } from './models-actions'; @@ -213,6 +217,11 @@ export function loadAnnotationsAsync(task: any, loader: any, file: File): ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { try { + const store = getCVATStore(); + const state: CombinedState = store.getState(); + if (state.tasks.activities.loads[task.id]) { + throw Error('Only one loading of annotations for a task allowed at the same time'); + } dispatch(loadAnnotations(task, loader)); await task.annotations.upload(file, loader); } catch (error) { diff --git a/cvat-ui/src/components/actions-menu/actions-menu.tsx b/cvat-ui/src/components/actions-menu/actions-menu.tsx index 61b2cc112ae4..d1087d2f0bda 100644 --- a/cvat-ui/src/components/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/components/actions-menu/actions-menu.tsx @@ -8,135 +8,151 @@ import { import { ClickParam } from 'antd/lib/menu/index'; -import LoaderItemComponent from './loader-item'; -import DumperItemComponent from './dumper-item'; -import ExportItemComponent from './export-item'; +import DumpSubmenu from './dump-submenu'; +import LoadSubmenu from './load-submenu'; +import ExportSubmenu from './export-submenu'; -interface ActionsMenuComponentProps { - taskInstance: any; - loaders: any[]; - dumpers: any[]; - exporters: any[]; +interface Props { + taskID: number; + taskMode: string; + bugTracker: string; + + loaders: string[]; + dumpers: string[]; + exporters: string[]; loadActivity: string | null; dumpActivities: string[] | null; exportActivities: string[] | null; + installedTFAnnotation: boolean; installedTFSegmentation: boolean; installedAutoAnnotation: boolean; inferenceIsActive: boolean; - onLoadAnnotation: (taskInstance: any, loader: any, file: File) => void; - onDumpAnnotation: (taskInstance: any, dumper: any) => void; - onExportDataset: (taskInstance: any, exporter: any) => void; - onDeleteTask: (taskInstance: any) => void; - onOpenRunWindow: (taskInstance: any) => void; -} -interface MinActionsMenuProps { - taskInstance: any; - onDeleteTask: (task: any) => void; - onOpenRunWindow: (taskInstance: any) => void; + onClickMenu: (params: ClickParam, file?: File) => void; } -export function handleMenuClick(props: MinActionsMenuProps, params: ClickParam): void { - const { taskInstance } = props; - const tracker = taskInstance.bugTracker; - - if (params.keyPath.length !== 2) { - switch (params.key) { - case 'tracker': { - // false positive eslint(security/detect-non-literal-fs-filename) - // eslint-disable-next-line - window.open(`${tracker}`, '_blank'); - return; - } case 'auto_annotation': { - props.onOpenRunWindow(taskInstance); - return; - } case 'delete': { - const taskID = taskInstance.id; - Modal.confirm({ - title: `The task ${taskID} will be deleted`, - content: 'All related data (images, annotations) will be lost. Continue?', - onOk: () => { - props.onDeleteTask(taskInstance); - }, - }); - break; - } default: { - // do nothing - } - } - } +export enum Actions { + DUMP_TASK_ANNO = 'dump_task_anno', + LOAD_TASK_ANNO = 'load_task_anno', + EXPORT_TASK_DATASET = 'export_task_dataset', + DELETE_TASK = 'delete_task', + RUN_AUTO_ANNOTATION = 'run_auto_annotation', + OPEN_BUG_TRACKER = 'open_bug_tracker', } -export default function ActionsMenuComponent(props: ActionsMenuComponentProps): JSX.Element { +export default function ActionsMenuComponent(props: Props): JSX.Element { const { - taskInstance, + taskID, + taskMode, + bugTracker, + installedAutoAnnotation, installedTFAnnotation, installedTFSegmentation, + inferenceIsActive, + dumpers, loaders, exporters, - inferenceIsActive, + onClickMenu, + dumpActivities, + exportActivities, + loadActivity, } = props; - const tracker = taskInstance.bugTracker; + const renderModelRunner = installedAutoAnnotation || installedTFAnnotation || installedTFSegmentation; + let latestParams: ClickParam | null = null; + function onClickMenuWrapper(params: ClickParam | null, file?: File): void { + const copyParams = params || latestParams; + if (!copyParams) { + return; + } + latestParams = copyParams; + + if (copyParams.keyPath.length === 2) { + const [, action] = copyParams.keyPath; + if (action === Actions.LOAD_TASK_ANNO) { + if (file) { + Modal.confirm({ + title: 'Current annotation will be lost', + content: 'You are going to upload new annotations to this task. Continue?', + onOk: () => { + onClickMenu(copyParams, file); + }, + okButtonProps: { + type: 'danger', + }, + okText: 'Update', + }); + } + } else { + onClickMenu(copyParams); + } + } else if (copyParams.key === Actions.DELETE_TASK) { + Modal.confirm({ + title: `The task ${taskID} will be deleted`, + content: 'All related data (images, annotations) will be lost. Continue?', + onOk: () => { + onClickMenu(copyParams); + }, + okButtonProps: { + type: 'danger', + }, + okText: 'Delete', + }); + } else { + onClickMenu(copyParams); + } + } + return ( handleMenuClick(props, params) - } + onClick={onClickMenuWrapper} > - - { - dumpers.map((dumper): JSX.Element => DumperItemComponent({ - dumper, - taskInstance: props.taskInstance, - dumpActivity: (props.dumpActivities || []) - .filter((_dumper: string) => _dumper === dumper.name)[0] || null, - onDumpAnnotation: props.onDumpAnnotation, - })) - } - - - { - loaders.map((loader): JSX.Element => LoaderItemComponent({ - loader, - taskInstance: props.taskInstance, - loadActivity: props.loadActivity, - onLoadAnnotation: props.onLoadAnnotation, - })) - } - - - { - exporters.map((exporter): JSX.Element => ExportItemComponent({ - exporter, - taskInstance: props.taskInstance, - exportActivity: (props.exportActivities || []) - .filter((_exporter: string) => _exporter === exporter.name)[0] || null, - onExportDataset: props.onExportDataset, - })) - } - - {tracker && Open bug tracker} + { + DumpSubmenu({ + taskMode, + dumpers, + dumpActivities, + menuKey: Actions.DUMP_TASK_ANNO, + }) + } + { + LoadSubmenu({ + loaders, + loadActivity, + onFileUpload: (file: File): void => { + onClickMenuWrapper(null, file); + }, + menuKey: Actions.LOAD_TASK_ANNO, + }) + } + { + ExportSubmenu({ + exporters, + exportActivities, + menuKey: Actions.EXPORT_TASK_DATASET, + }) + } + {!!bugTracker && Open bug tracker} { renderModelRunner && ( Automatic annotation ) }
- Delete + Delete
); } diff --git a/cvat-ui/src/components/actions-menu/dump-submenu.tsx b/cvat-ui/src/components/actions-menu/dump-submenu.tsx new file mode 100644 index 000000000000..a3fa5bdee8e0 --- /dev/null +++ b/cvat-ui/src/components/actions-menu/dump-submenu.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import { + Menu, + Icon, +} from 'antd'; + +import Text from 'antd/lib/typography/Text'; + +function isDefaultFormat(dumperName: string, taskMode: string): boolean { + return (dumperName === 'CVAT XML 1.1 for videos' && taskMode === 'interpolation') + || (dumperName === 'CVAT XML 1.1 for images' && taskMode === 'annotation'); +} + +interface Props { + taskMode: string; + menuKey: string; + dumpers: string[]; + dumpActivities: string[] | null; +} + +export default function DumpSubmenu(props: Props): JSX.Element { + const { + taskMode, + menuKey, + dumpers, + dumpActivities, + } = props; + + return ( + + { + dumpers.map((dumper: string): JSX.Element => { + const pending = (dumpActivities || []).includes(dumper); + const isDefault = isDefaultFormat(dumper, taskMode); + return ( + + + {dumper} + {pending && } + + ); + }) + } + + ); +} diff --git a/cvat-ui/src/components/actions-menu/dumper-item.tsx b/cvat-ui/src/components/actions-menu/dumper-item.tsx deleted file mode 100644 index 37db1a6c7f8a..000000000000 --- a/cvat-ui/src/components/actions-menu/dumper-item.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; - -import { - Menu, - Button, - Icon, -} from 'antd'; - -import Text from 'antd/lib/typography/Text'; - -interface DumperItemComponentProps { - taskInstance: any; - dumper: any; - dumpActivity: string | null; - onDumpAnnotation: (task: any, dumper: any) => void; -} - -function isDefaultFormat(dumperName: string, taskMode: string): boolean { - return (dumperName === 'CVAT XML 1.1 for videos' && taskMode === 'interpolation') - || (dumperName === 'CVAT XML 1.1 for images' && taskMode === 'annotation'); -} - -export default function DumperItemComponent(props: DumperItemComponentProps): JSX.Element { - const { - taskInstance, - dumpActivity, - } = props; - const { mode } = taskInstance; - const { dumper } = props; - const pending = !!dumpActivity; - - return ( - - - - ); -} diff --git a/cvat-ui/src/components/actions-menu/export-item.tsx b/cvat-ui/src/components/actions-menu/export-item.tsx deleted file mode 100644 index 9dba300bdfaa..000000000000 --- a/cvat-ui/src/components/actions-menu/export-item.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; - -import { - Menu, - Button, - Icon, -} from 'antd'; - -import Text from 'antd/lib/typography/Text'; - -interface DumperItemComponentProps { - taskInstance: any; - exporter: any; - exportActivity: string | null; - onExportDataset: (task: any, exporter: any) => void; -} - -export default function DumperItemComponent(props: DumperItemComponentProps): JSX.Element { - const { - taskInstance, - exporter, - exportActivity, - } = props; - - const pending = !!exportActivity; - - return ( - - - - ); -} diff --git a/cvat-ui/src/components/actions-menu/export-submenu.tsx b/cvat-ui/src/components/actions-menu/export-submenu.tsx new file mode 100644 index 000000000000..24a525be1148 --- /dev/null +++ b/cvat-ui/src/components/actions-menu/export-submenu.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { + Menu, + Icon, +} from 'antd'; + +import Text from 'antd/lib/typography/Text'; + +interface Props { + menuKey: string; + exporters: string[]; + exportActivities: string[] | null; +} + +export default function ExportSubmenu(props: Props): JSX.Element { + const { + menuKey, + exporters, + exportActivities, + } = props; + + return ( + + { + exporters.map((exporter: string): JSX.Element => { + const pending = (exportActivities || []).includes(exporter); + return ( + + + {exporter} + {pending && } + + ); + }) + } + + ); +} diff --git a/cvat-ui/src/components/actions-menu/load-submenu.tsx b/cvat-ui/src/components/actions-menu/load-submenu.tsx new file mode 100644 index 000000000000..1b1a387e3c8e --- /dev/null +++ b/cvat-ui/src/components/actions-menu/load-submenu.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { + Menu, + Icon, + Upload, + Button, +} from 'antd'; + +import Text from 'antd/lib/typography/Text'; + +interface Props { + menuKey: string; + loaders: string[]; + loadActivity: string | null; + onFileUpload(file: File): void; +} + +export default function LoadSubmenu(props: Props): JSX.Element { + const { + menuKey, + loaders, + loadActivity, + onFileUpload, + } = props; + + return ( + + { + loaders.map((_loader: string): JSX.Element => { + const [loader, accept] = _loader.split('::'); + const pending = loadActivity === loader; + return ( + + { + onFileUpload(file); + return false; + }} + > + + + + + ); + }) + } + + ); +} diff --git a/cvat-ui/src/components/actions-menu/loader-item.tsx b/cvat-ui/src/components/actions-menu/loader-item.tsx deleted file mode 100644 index 5f736c7e8702..000000000000 --- a/cvat-ui/src/components/actions-menu/loader-item.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; - -import { - Menu, - Button, - Icon, - Upload, -} from 'antd'; - -import { RcFile } from 'antd/lib/upload'; -import Text from 'antd/lib/typography/Text'; - -interface LoaderItemComponentProps { - taskInstance: any; - loader: any; - loadActivity: string | null; - onLoadAnnotation: (taskInstance: any, loader: any, file: File) => void; -} - -export default function LoaderItemComponent(props: LoaderItemComponentProps): JSX.Element { - const { - loader, - loadActivity, - } = props; - - const loadingWithThisLoader = loadActivity - && loadActivity === loader.name - ? loadActivity : null; - - const pending = !!loadingWithThisLoader; - - return ( - - { - props.onLoadAnnotation( - props.taskInstance, - loader, - file as File, - ); - - return false; - }} - > - - - - ); -} diff --git a/cvat-ui/src/components/actions-menu/styles.scss b/cvat-ui/src/components/actions-menu/styles.scss index 9a348dde1366..a84dd35af760 100644 --- a/cvat-ui/src/components/actions-menu/styles.scss +++ b/cvat-ui/src/components/actions-menu/styles.scss @@ -7,23 +7,37 @@ background-color: $hover-menu-color; } - .ant-menu-submenu-arrow { - width: 0px; + .ant-menu-submenu-title { + margin: 0px; + width: 13em; } } -.cvat-actions-menu-load-submenu-item, -.cvat-actions-menu-dump-submenu-item, -.cvat-actions-menu-export-submenu-item { +.cvat-menu-load-submenu-item, +.cvat-menu-dump-submenu-item, +.cvat-menu-export-submenu-item { + > i { + color: $info-icon-color; + } + &:hover { background-color: $hover-menu-color; } } -.cvat-actions-menu-dump-submenu-item, -.cvat-actions-menu-export-submenu-item { - > button { - text-align: left; +.ant-menu-item.cvat-menu-load-submenu-item { + margin: 0px; + padding: 0px; + + > span > .ant-upload { + width: 100%; + height: 100%; + + > span > button { + width: 100%; + height: 100%; + text-align: left; + } } } 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/components/annotation-page/styles.scss b/cvat-ui/src/components/annotation-page/styles.scss index 3d77640a070f..e1c039fa0796 100644 --- a/cvat-ui/src/components/annotation-page/styles.scss +++ b/cvat-ui/src/components/annotation-page/styles.scss @@ -181,4 +181,17 @@ } } } -} \ No newline at end of file +} + +.ant-menu.cvat-annotation-menu { + box-shadow: 0 0 17px rgba(0,0,0,0.2); + + > li:hover { + background-color: $hover-menu-color; + } + + .ant-menu-submenu-title { + margin: 0px; + width: 15em; + } +} diff --git a/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx new file mode 100644 index 000000000000..ece7d2d215a8 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx @@ -0,0 +1,125 @@ +import React from 'react'; + +import { + Menu, Modal, +} from 'antd'; + +import { ClickParam } from 'antd/lib/menu/index'; + +import DumpSubmenu from 'components/actions-menu/dump-submenu'; +import LoadSubmenu from 'components/actions-menu/load-submenu'; +import ExportSubmenu from 'components/actions-menu/export-submenu'; + +interface Props { + taskMode: string; + loaders: string[]; + dumpers: string[]; + exporters: string[]; + loadActivity: string | null; + dumpActivities: string[] | null; + exportActivities: string[] | null; + onClickMenu(params: ClickParam, file?: File): void; +} + +export enum Actions { + DUMP_TASK_ANNO = 'dump_task_anno', + LOAD_JOB_ANNO = 'load_job_anno', + EXPORT_TASK_DATASET = 'export_task_dataset', + REMOVE_ANNO = 'remove_anno', + OPEN_TASK = 'open_task', +} + +export default function AnnotationMenuComponent(props: Props): JSX.Element { + const { + taskMode, + loaders, + dumpers, + exporters, + onClickMenu, + loadActivity, + dumpActivities, + exportActivities, + } = props; + + let latestParams: ClickParam | null = null; + function onClickMenuWrapper(params: ClickParam | null, file?: File): void { + const copyParams = params || latestParams; + if (!copyParams) { + return; + } + latestParams = params; + + if (copyParams.keyPath.length === 2) { + const [, action] = copyParams.keyPath; + if (action === Actions.LOAD_JOB_ANNO) { + if (file) { + Modal.confirm({ + title: 'Current annotation will be lost', + content: 'You are going to upload new annotations to this job. Continue?', + onOk: () => { + onClickMenu(copyParams, file); + }, + okButtonProps: { + type: 'danger', + }, + okText: 'Update', + }); + } + } else { + onClickMenu(copyParams); + } + } else if (copyParams.key === Actions.REMOVE_ANNO) { + Modal.confirm({ + title: 'All annotations will be removed', + content: 'You are goung to remove all annotations from the client. ' + + 'It will stay on the server till you save a job. Continue?', + onOk: () => { + onClickMenu(copyParams); + }, + okButtonProps: { + type: 'danger', + }, + okText: 'Delete', + }); + } else { + onClickMenu(copyParams); + } + } + + return ( + + { + DumpSubmenu({ + taskMode, + dumpers, + dumpActivities, + menuKey: Actions.DUMP_TASK_ANNO, + }) + } + { + LoadSubmenu({ + loaders, + loadActivity, + onFileUpload: (file: File): void => { + onClickMenuWrapper(null, file); + }, + menuKey: Actions.LOAD_JOB_ANNO, + }) + } + { + ExportSubmenu({ + exporters, + exportActivities, + menuKey: Actions.EXPORT_TASK_DATASET, + }) + } + + + Remove annotations + + + Open the task + + + ); +} diff --git a/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx b/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx index 486e286fd362..c76cf232c767 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx @@ -6,8 +6,11 @@ import { Modal, Button, Timeline, + Dropdown, } from 'antd'; +import AnnotationMenuContainer from 'containers/annotation-page/top-bar/annotation-menu'; + import { MainMenuIcon, SaveIcon, @@ -30,10 +33,12 @@ function LeftGroup(props: Props): JSX.Element { return ( - + }> + +