diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts index 2413368f6f4..60745f9f3ec 100644 --- a/cvat-canvas/src/typescript/drawHandler.ts +++ b/cvat-canvas/src/typescript/drawHandler.ts @@ -34,6 +34,10 @@ export class DrawHandlerImpl implements DrawHandler { private onDrawDone: (data: object, continueDraw?: boolean) => void; private canvas: SVG.Container; private text: SVG.Container; + private cursorPosition: { + x: number; + y: number; + }; private crosshair: { x: SVG.Line; y: SVG.Line; @@ -96,12 +100,13 @@ export class DrawHandlerImpl implements DrawHandler { } private addCrosshair(): void { + const { x, y } = this.cursorPosition; this.crosshair = { - x: this.canvas.line(0, 0, this.canvas.node.clientWidth, 0).attr({ + x: this.canvas.line(0, y, this.canvas.node.clientWidth, y).attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * this.geometry.scale), zOrder: Number.MAX_SAFE_INTEGER, }).addClass('cvat_canvas_crosshair'), - y: this.canvas.line(0, 0, 0, this.canvas.node.clientHeight).attr({ + y: this.canvas.line(x, 0, x, this.canvas.node.clientHeight).attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * this.geometry.scale), zOrder: Number.MAX_SAFE_INTEGER, }).addClass('cvat_canvas_crosshair'), @@ -181,7 +186,6 @@ export class DrawHandlerImpl implements DrawHandler { this.shapeSizeElement.update(this.drawInstance); }).addClass('cvat_canvas_shape_drawing').attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - z_order: Number.MAX_SAFE_INTEGER, }); } @@ -222,10 +226,6 @@ export class DrawHandlerImpl implements DrawHandler { } private drawPolyshape(): void { - this.drawInstance.attr({ - z_order: Number.MAX_SAFE_INTEGER, - }); - let size = this.drawData.numberOfPoints; const sizeDecrement = function sizeDecrement(): void { if (!--size) { @@ -371,18 +371,17 @@ export class DrawHandlerImpl implements DrawHandler { // Common settings for rectangle and polyshapes private pasteShape(): void { - this.drawInstance.attr({ - z_order: Number.MAX_SAFE_INTEGER, - }); + function moveShape(shape: SVG.Shape, x: number, y: number): void { + const bbox = shape.bbox(); + shape.move(x - bbox.width / 2, y - bbox.height / 2); + } - this.canvas.on('mousemove.draw', (e: MouseEvent): void => { - const [x, y] = translateToSVG( - this.canvas.node as any as SVGSVGElement, - [e.clientX, e.clientY], - ); + const { x: initialX, y: initialY } = this.cursorPosition; + moveShape(this.drawInstance, initialX, initialY); - const bbox = this.drawInstance.bbox(); - this.drawInstance.move(x - bbox.width / 2, y - bbox.height / 2); + this.canvas.on('mousemove.draw', (): void => { + const { x, y } = this.cursorPosition; // was computer in another callback + moveShape(this.drawInstance, x, y); }); } @@ -429,45 +428,53 @@ export class DrawHandlerImpl implements DrawHandler { this.pastePolyshape(); } - private pastePoints(points: string): void { - this.drawInstance = (this.canvas as any).polyline(points) + private pastePoints(initialPoints: string): void { + function moveShape( + shape: SVG.PolyLine, + group: SVG.G, + x: number, + y: number, + scale: number, + ): void { + const bbox = shape.bbox(); + shape.move(x - bbox.width / 2, y - bbox.height / 2); + + const points = shape.attr('points').split(' '); + const radius = consts.BASE_POINT_SIZE / scale; + + group.children().forEach((child: SVG.Element, idx: number): void => { + const [px, py] = points[idx].split(','); + child.move(px - radius / 2, py - radius / 2); + }); + } + + const { x: initialX, y: initialY } = this.cursorPosition; + this.pointsGroup = this.canvas.group(); + this.drawInstance = (this.canvas as any).polyline(initialPoints) .addClass('cvat_canvas_shape_drawing').style({ 'stroke-width': 0, }); - this.pointsGroup = this.canvas.group(); - for (const point of points.split(' ')) { + let numOfPoints = initialPoints.split(' ').length; + while (numOfPoints) { + numOfPoints--; const radius = consts.BASE_POINT_SIZE / this.geometry.scale; const stroke = consts.POINTS_STROKE_WIDTH / this.geometry.scale; - const [x, y] = point.split(',').map((coord: string): number => +coord); - this.pointsGroup.circle().move(x - radius / 2, y - radius / 2) - .fill('white').stroke('black').attr({ - r: radius, - 'stroke-width': stroke, - }); + this.pointsGroup.circle().fill('white').stroke('black').attr({ + r: radius, + 'stroke-width': stroke, + }); } - this.pointsGroup.attr({ - z_order: Number.MAX_SAFE_INTEGER, - }); + moveShape( + this.drawInstance, this.pointsGroup, initialX, initialY, this.geometry.scale, + ); - this.canvas.on('mousemove.draw', (e: MouseEvent): void => { - const [x, y] = translateToSVG( - this.canvas.node as any as SVGSVGElement, - [e.clientX, e.clientY], + this.canvas.on('mousemove.draw', (): void => { + const { x, y } = this.cursorPosition; // was computer in another callback + moveShape( + this.drawInstance, this.pointsGroup, x, y, this.geometry.scale, ); - - const bbox = this.drawInstance.bbox(); - this.drawInstance.move(x - bbox.width / 2, y - bbox.height / 2); - const radius = consts.BASE_POINT_SIZE / this.geometry.scale; - const newPoints = this.drawInstance.attr('points').split(' '); - if (this.pointsGroup) { - this.pointsGroup.children() - .forEach((child: SVG.Element, idx: number): void => { - const [px, py] = newPoints[idx].split(','); - child.move(px - radius / 2, py - radius / 2); - }); - } }); this.pastePolyshape(); @@ -593,23 +600,20 @@ export class DrawHandlerImpl implements DrawHandler { this.crosshair = null; this.drawInstance = null; this.pointsGroup = null; + this.cursorPosition = { + x: 0, + y: 0, + }; this.canvas.on('mousemove.crosshair', (e: MouseEvent): void => { + const [x, y] = translateToSVG( + this.canvas.node as any as SVGSVGElement, + [e.clientX, e.clientY], + ); + this.cursorPosition = { x, y }; if (this.crosshair) { - const [x, y] = translateToSVG( - this.canvas.node as any as SVGSVGElement, - [e.clientX, e.clientY], - ); - - this.crosshair.x.attr({ - y1: y, - y2: y, - }); - - this.crosshair.y.attr({ - x1: x, - x2: x, - }); + this.crosshair.x.attr({ y1: y, y2: y }); + this.crosshair.y.attr({ x1: x, x2: x }); } }); } diff --git a/cvat-core/src/annotations-collection.js b/cvat-core/src/annotations-collection.js index 759980dbe2a..a3e49f1505c 100644 --- a/cvat-core/src/annotations-collection.js +++ b/cvat-core/src/annotations-collection.js @@ -862,7 +862,7 @@ : (frame) => frame - 1; for (let frame = frameFrom; predicate(frame); frame = update(frame)) { // First prepare all data for the frame - // Consider all shapes, tags, and tracks that have keyframe here + // Consider all shapes, tags, and not outside tracks that have keyframe here // In particular consider first and last frame as keyframes for all frames const statesData = [].concat( (frame in this.shapes ? this.shapes[frame] : []) @@ -876,7 +876,10 @@ || frame === frameFrom || frame === frameTo )); - statesData.push(...tracks.map((track) => track.get(frame))); + statesData.push( + ...tracks.map((track) => track.get(frame)) + .filter((state) => !state.outside), + ); // Nothing to filtering, go to the next iteration if (!statesData.length) { diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js index e3de1cb0e17..32643dd200b 100644 --- a/cvat-core/src/session.js +++ b/cvat-core/src/session.js @@ -1343,7 +1343,7 @@ return annotationsData; }; - Job.prototype.annotations.search.implementation = async function (filters, frameFrom, frameTo) { + Job.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) { if (!Array.isArray(filters) || filters.some((filter) => typeof (filter) !== 'string')) { throw new ArgumentError( 'The filters argument must be an array of strings', @@ -1555,7 +1555,7 @@ return result; }; - Job.prototype.annotations.search.implementation = async function (filters, frameFrom, frameTo) { + Task.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) { if (!Array.isArray(filters) || filters.some((filter) => typeof (filter) !== 'string')) { throw new ArgumentError( 'The filters argument must be an array of strings', diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index 3ff854fe28d..828118cfaaf 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "0.1.0", + "version": "0.5.2", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -9734,6 +9734,14 @@ "scheduler": "^0.17.0" } }, + "react-hotkeys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-hotkeys/-/react-hotkeys-2.0.0.tgz", + "integrity": "sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==", + "requires": { + "prop-types": "^15.6.1" + } + }, "react-is": { "version": "16.11.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 9c25aa8f6d9..60f6aa30c61 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -61,6 +61,7 @@ "prop-types": "^15.7.2", "react": "^16.9.0", "react-dom": "^16.9.0", + "react-hotkeys": "^2.0.0", "react-redux": "^7.1.1", "react-router": "^5.1.0", "react-router-dom": "^5.1.0", diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 2ee2b011c20..44aeb752ceb 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -82,8 +82,10 @@ export enum AnnotationActionTypes { GROUP_OBJECTS = 'GROUP_OBJECTS', SPLIT_TRACK = 'SPLIT_TRACK', COPY_SHAPE = 'COPY_SHAPE', + PASTE_SHAPE = 'PASTE_SHAPE', EDIT_SHAPE = 'EDIT_SHAPE', DRAW_SHAPE = 'DRAW_SHAPE', + REPEAT_DRAW_SHAPE = 'REPEAT_DRAW_SHAPE', SHAPE_DRAWN = 'SHAPE_DRAWN', RESET_CANVAS = 'RESET_CANVAS', UPDATE_ANNOTATIONS_SUCCESS = 'UPDATE_ANNOTATIONS_SUCCESS', @@ -92,6 +94,8 @@ export enum AnnotationActionTypes { CREATE_ANNOTATIONS_FAILED = 'CREATE_ANNOTATIONS_FAILED', MERGE_ANNOTATIONS_SUCCESS = 'MERGE_ANNOTATIONS_SUCCESS', MERGE_ANNOTATIONS_FAILED = 'MERGE_ANNOTATIONS_FAILED', + RESET_ANNOTATIONS_GROUP = 'RESET_ANNOTATIONS_GROUP', + GROUP_ANNOTATIONS = 'GROUP_ANNOTATIONS', GROUP_ANNOTATIONS_SUCCESS = 'GROUP_ANNOTATIONS_SUCCESS', GROUP_ANNOTATIONS_FAILED = 'GROUP_ANNOTATIONS_FAILED', SPLIT_ANNOTATIONS_SUCCESS = 'SPLIT_ANNOTATIONS_SUCCESS', @@ -133,6 +137,7 @@ export enum AnnotationActionTypes { ROTATE_FRAME = 'ROTATE_FRAME', SWITCH_Z_LAYER = 'SWITCH_Z_LAYER', ADD_Z_LAYER = 'ADD_Z_LAYER', + SEARCH_ANNOTATIONS_FAILED = 'SEARCH_ANNOTATIONS_FAILED', } export function addZLayer(): AnyAction { @@ -905,6 +910,11 @@ export function updateAnnotationsAsync(sessionInstance: any, frame: number, stat ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { try { + if (statesToUpdate.some((state: any): boolean => state.updateFlags.zOrder)) { + // deactivate object to visualize changes immediately (UX) + dispatch(activateObject(null)); + } + const promises = statesToUpdate .map((objectState: any): Promise => objectState.save()); const states = await Promise.all(promises); @@ -991,12 +1001,30 @@ ThunkAction, {}, {}, AnyAction> { }; } -export function groupAnnotationsAsync(sessionInstance: any, frame: number, statesToGroup: any[]): -ThunkAction, {}, {}, AnyAction> { +export function resetAnnotationsGroup(): AnyAction { + return { + type: AnnotationActionTypes.RESET_ANNOTATIONS_GROUP, + payload: {}, + }; +} + +export function groupAnnotationsAsync( + sessionInstance: any, + frame: number, + statesToGroup: any[], +): ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { try { const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters(); - await sessionInstance.annotations.group(statesToGroup); + const reset = getStore().getState().annotation.annotations.resetGroupFlag; + + // The action below set resetFlag to false + dispatch({ + type: AnnotationActionTypes.GROUP_ANNOTATIONS, + payload: {}, + }); + + await sessionInstance.annotations.group(statesToGroup, reset); const states = await sessionInstance.annotations .get(frame, showAllInterpolationTracks, filters); const history = await sessionInstance.actions.get(); @@ -1099,3 +1127,94 @@ export function changeGroupColorAsync( } }; } + +export function searchAnnotationsAsync( + sessionInstance: any, + frameFrom: number, + frameTo: number, +): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + try { + const { filters } = receiveAnnotationsParameters(); + const frame = await sessionInstance.annotations.search(filters, frameFrom, frameTo); + if (frame !== null) { + dispatch(changeFrameAsync(frame)); + } + } catch (error) { + dispatch({ + type: AnnotationActionTypes.SEARCH_ANNOTATIONS_FAILED, + payload: { + error, + }, + }); + } + }; +} + +export function pasteShapeAsync(): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + const initialState = getStore().getState().annotation.drawing.activeInitialState; + const { instance: canvasInstance } = getStore().getState().annotation.canvas; + + if (initialState) { + let activeControl = ActiveControl.DRAW_RECTANGLE; + if (initialState.shapeType === ShapeType.POINTS) { + activeControl = ActiveControl.DRAW_POINTS; + } else if (initialState.shapeType === ShapeType.POLYGON) { + activeControl = ActiveControl.DRAW_POLYGON; + } else if (initialState.shapeType === ShapeType.POLYLINE) { + activeControl = ActiveControl.DRAW_POLYLINE; + } + + dispatch({ + type: AnnotationActionTypes.PASTE_SHAPE, + payload: { + activeControl, + }, + }); + + canvasInstance.cancel(); + canvasInstance.draw({ + enabled: true, + initialState, + }); + } + }; +} + +export function repeatDrawShapeAsync(): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + const { + activeShapeType, + activeNumOfPoints, + activeRectDrawingMethod, + } = getStore().getState().annotation.drawing; + + const { instance: canvasInstance } = getStore().getState().annotation.canvas; + + let activeControl = ActiveControl.DRAW_RECTANGLE; + if (activeShapeType === ShapeType.POLYGON) { + activeControl = ActiveControl.DRAW_POLYGON; + } else if (activeShapeType === ShapeType.POLYLINE) { + activeControl = ActiveControl.DRAW_POLYLINE; + } else if (activeShapeType === ShapeType.POINTS) { + activeControl = ActiveControl.DRAW_POINTS; + } + + dispatch({ + type: AnnotationActionTypes.REPEAT_DRAW_SHAPE, + payload: { + activeControl, + }, + }); + + canvasInstance.cancel(); + canvasInstance.draw({ + enabled: true, + rectDrawingMethod: activeRectDrawingMethod, + numberOfPoints: activeNumOfPoints, + shapeType: activeShapeType, + crosshair: activeShapeType === ShapeType.RECTANGLE, + }); + }; +} diff --git a/cvat-ui/src/actions/shortcuts-actions.ts b/cvat-ui/src/actions/shortcuts-actions.ts new file mode 100644 index 00000000000..86d83e6d707 --- /dev/null +++ b/cvat-ui/src/actions/shortcuts-actions.ts @@ -0,0 +1,11 @@ +import { ActionUnion, createAction } from 'utils/redux'; + +export enum ShortcutsActionsTypes { + SWITCH_SHORTCUT_DIALOG = 'SWITCH_SHORTCUT_DIALOG', +} + +export const shortcutsActions = { + switchShortcutsDialog: () => createAction(ShortcutsActionsTypes.SWITCH_SHORTCUT_DIALOG), +}; + +export type ShortcutsActions = ActionUnion; 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 32088df5de5..1b5d750820d 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 @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT import React from 'react'; +import { GlobalHotKeys, KeyMap } from 'react-hotkeys'; import { Layout, @@ -12,17 +13,8 @@ import { } from 'antd'; import { SliderValue } from 'antd/lib//slider'; - -import { - ColorBy, - GridColor, - ObjectType, -} from 'reducers/interfaces'; - -import { - Canvas, -} from 'cvat-canvas'; - +import { ColorBy, GridColor, ObjectType } from 'reducers/interfaces'; +import { Canvas } from 'cvat-canvas'; import getCore from 'cvat-core'; const cvat = getCore(); @@ -75,6 +67,12 @@ interface Props { onUpdateContextMenu(visible: boolean, left: number, top: number): void; onAddZLayer(): void; onSwitchZLayer(cur: number): void; + onChangeBrightnessLevel(level: number): void; + onChangeContrastLevel(level: number): void; + onChangeSaturationLevel(level: number): void; + onChangeGridOpacity(opacity: number): void; + onChangeGridColor(color: GridColor): void; + onSwitchGrid(enabled: boolean): void; } export default class CanvasWrapperComponent extends React.PureComponent { @@ -109,6 +107,12 @@ export default class CanvasWrapperComponent extends React.PureComponent { activatedStateID, curZLayer, resetZoom, + grid, + gridOpacity, + gridColor, + brightnessLevel, + contrastLevel, + saturationLevel, } = this.props; if (prevProps.sidebarCollapsed !== sidebarCollapsed) { @@ -132,6 +136,31 @@ export default class CanvasWrapperComponent extends React.PureComponent { } } + if (gridOpacity !== prevProps.gridOpacity + || gridColor !== prevProps.gridColor + || grid !== prevProps.grid) { + const gridElement = window.document.getElementById('cvat_canvas_grid'); + const gridPattern = window.document.getElementById('cvat_canvas_grid_pattern'); + if (gridElement) { + gridElement.style.display = grid ? 'block' : 'none'; + } + if (gridPattern) { + gridPattern.style.stroke = gridColor.toLowerCase(); + gridPattern.style.opacity = `${gridOpacity / 100}`; + } + } + + if (brightnessLevel !== prevProps.brightnessLevel + || contrastLevel !== prevProps.contrastLevel + || saturationLevel !== prevProps.saturationLevel) { + const backgroundElement = window.document.getElementById('cvat_canvas_background'); + if (backgroundElement) { + backgroundElement.style.filter = `brightness(${brightnessLevel / 100})` + + `contrast(${contrastLevel / 100})` + + `saturate(${saturationLevel / 100})`; + } + } + if (prevProps.annotations !== annotations || prevProps.frameData !== frameData) { this.updateCanvas(); } @@ -360,7 +389,9 @@ export default class CanvasWrapperComponent extends React.PureComponent { // Filters const backgroundElement = window.document.getElementById('cvat_canvas_background'); if (backgroundElement) { - backgroundElement.style.filter = `brightness(${brightnessLevel / 100}) contrast(${contrastLevel / 100}) saturate(${saturationLevel / 100})`; + backgroundElement.style.filter = `brightness(${brightnessLevel / 100})` + + `contrast(${contrastLevel / 100})` + + `saturate(${saturationLevel / 100})`; } // Events @@ -374,6 +405,12 @@ export default class CanvasWrapperComponent extends React.PureComponent { } }); + canvasInstance.html().addEventListener('click', (): void => { + if (document.activeElement) { + (document.activeElement as HTMLElement).blur(); + } + }); + canvasInstance.html().addEventListener('contextmenu', (e: MouseEvent): void => { const { activatedStateID, @@ -488,10 +525,162 @@ export default class CanvasWrapperComponent extends React.PureComponent { minZLayer, onSwitchZLayer, onAddZLayer, + brightnessLevel, + contrastLevel, + saturationLevel, + grid, + gridColor, + gridOpacity, + onChangeBrightnessLevel, + onChangeSaturationLevel, + onChangeContrastLevel, + onChangeGridColor, + onChangeGridOpacity, + onSwitchGrid, } = this.props; + const preventDefault = (event: KeyboardEvent | undefined): void => { + if (event) { + event.preventDefault(); + } + }; + + const keyMap = { + INCREASE_BRIGHTNESS: { + name: 'Brightness+', + description: 'Increase brightness level for the image', + sequence: 'shift+b+=', + action: 'keypress', + }, + DECREASE_BRIGHTNESS: { + name: 'Brightness-', + description: 'Decrease brightness level for the image', + sequence: 'shift+b+-', + action: 'keydown', + }, + INCREASE_CONTRAST: { + name: 'Contrast+', + description: 'Increase contrast level for the image', + sequence: 'shift+c+=', + action: 'keydown', + }, + DECREASE_CONTRAST: { + name: 'Contrast-', + description: 'Decrease contrast level for the image', + sequence: 'shift+c+-', + action: 'keydown', + }, + INCREASE_SATURATION: { + name: 'Saturation+', + description: 'Increase saturation level for the image', + sequence: 'shift+s+=', + action: 'keydown', + }, + DECREASE_SATURATION: { + name: 'Saturation-', + description: 'Increase contrast level for the image', + sequence: 'shift+s+-', + action: 'keydown', + }, + INCREASE_GRID_OPACITY: { + name: 'Grid opacity+', + description: 'Make the grid more visible', + sequence: 'shift+g+=', + action: 'keydown', + }, + DECREASE_GRID_OPACITY: { + name: 'Grid opacity-', + description: 'Make the grid less visible', + sequences: 'shift+g+-', + action: 'keydown', + }, + CHANGE_GRID_COLOR: { + name: 'Grid color', + description: 'Set another color for the image grid', + sequence: 'shift+g+enter', + action: 'keydown', + }, + }; + + const step = 10; + const handlers = { + INCREASE_BRIGHTNESS: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const maxLevel = 200; + if (brightnessLevel < maxLevel) { + onChangeBrightnessLevel(Math.min(brightnessLevel + step, maxLevel)); + } + }, + DECREASE_BRIGHTNESS: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const minLevel = 50; + if (brightnessLevel > minLevel) { + onChangeBrightnessLevel(Math.max(brightnessLevel - step, minLevel)); + } + }, + INCREASE_CONTRAST: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const maxLevel = 200; + if (contrastLevel < maxLevel) { + onChangeContrastLevel(Math.min(contrastLevel + step, maxLevel)); + } + }, + DECREASE_CONTRAST: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const minLevel = 50; + if (contrastLevel > minLevel) { + onChangeContrastLevel(Math.max(contrastLevel - step, minLevel)); + } + }, + INCREASE_SATURATION: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const maxLevel = 300; + if (saturationLevel < maxLevel) { + onChangeSaturationLevel(Math.min(saturationLevel + step, maxLevel)); + } + }, + DECREASE_SATURATION: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const minLevel = 0; + if (saturationLevel > minLevel) { + onChangeSaturationLevel(Math.max(saturationLevel - step, minLevel)); + } + }, + INCREASE_GRID_OPACITY: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const maxLevel = 100; + if (!grid) { + onSwitchGrid(true); + } + + if (gridOpacity < maxLevel) { + onChangeGridOpacity(Math.min(gridOpacity + step, maxLevel)); + } + }, + DECREASE_GRID_OPACITY: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const minLevel = 0; + if (gridOpacity - step <= minLevel) { + onSwitchGrid(false); + } + + if (gridOpacity > minLevel) { + onChangeGridOpacity(Math.max(gridOpacity - step, minLevel)); + } + }, + CHANGE_GRID_COLOR: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const colors = [GridColor.Black, GridColor.Blue, + GridColor.Green, GridColor.Red, GridColor.White]; + const indexOf = colors.indexOf(gridColor) + 1; + const color = colors[indexOf >= colors.length ? 0 : indexOf]; + onChangeGridColor(color); + }, + }; + return ( + {/* This element doesn't have any props So, React isn't going to rerender it diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx index 176c384b44b..7deeabb49b9 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT import React from 'react'; +import { GlobalHotKeys, KeyMap } from 'react-hotkeys'; import { Icon, @@ -12,7 +13,7 @@ import { import { ActiveControl, - Rotation + Rotation, } from 'reducers/interfaces'; import { @@ -44,6 +45,9 @@ interface Props { groupObjects(enabled: boolean): void; splitTrack(enabled: boolean): void; rotateFrame(rotation: Rotation): void; + repeatDrawShape(): void; + pasteShape(): void; + resetGroup(): void; } export default function ControlsSideBarComponent(props: Props): JSX.Element { @@ -55,14 +59,140 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { groupObjects, splitTrack, rotateFrame, + repeatDrawShape, + pasteShape, + resetGroup, } = props; + const preventDefault = (event: KeyboardEvent | undefined): void => { + if (event) { + event.preventDefault(); + } + }; + + const keyMap = { + PASTE_SHAPE: { + name: 'Paste shape', + description: 'Paste a shape from internal CVAT clipboard', + sequence: 'ctrl+v', + action: 'keydown', + }, + SWITCH_DRAW_MODE: { + name: 'Draw mode', + description: 'Repeat the latest procedure of drawing with the same parameters', + sequence: 'n', + action: 'keydown', + }, + SWITCH_MERGE_MODE: { + name: 'Merge mode', + description: 'Activate or deactivate mode to merging shapes', + sequence: 'm', + action: 'keydown', + }, + SWITCH_GROUP_MODE: { + name: 'Group mode', + description: 'Activate or deactivate mode to grouping shapes', + sequence: 'g', + action: 'keydown', + }, + RESET_GROUP: { + name: 'Reset group', + description: 'Reset group for selected shapes (in group mode)', + sequence: 'shift+g', + action: 'keyup', + }, + CANCEL: { + name: 'Cancel', + description: 'Cancel any active canvas mode', + sequence: 'esc', + action: 'keydown', + }, + CLOCKWISE_ROTATION: { + name: 'Rotate clockwise', + description: 'Change image angle (add 90 degrees)', + sequence: 'ctrl+r', + action: 'keydown', + }, + ANTICLOCKWISE_ROTATION: { + name: 'Rotate anticlockwise', + description: 'Change image angle (substract 90 degrees)', + sequence: 'ctrl+shift+r', + action: 'keydown', + }, + }; + + const handlers = { + PASTE_SHAPE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + canvasInstance.cancel(); + pasteShape(); + }, + SWITCH_DRAW_MODE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const drawing = [ActiveControl.DRAW_POINTS, ActiveControl.DRAW_POLYGON, + ActiveControl.DRAW_POLYLINE, ActiveControl.DRAW_RECTANGLE].includes(activeControl); + + if (!drawing) { + canvasInstance.cancel(); + // repeateDrawShapes gets all the latest parameters + // and calls canvasInstance.draw() with them + repeatDrawShape(); + } else { + canvasInstance.draw({ enabled: false }); + } + }, + SWITCH_MERGE_MODE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const merging = activeControl === ActiveControl.MERGE; + if (!merging) { + canvasInstance.cancel(); + } + canvasInstance.merge({ enabled: !merging }); + mergeObjects(!merging); + }, + SWITCH_GROUP_MODE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const grouping = activeControl === ActiveControl.GROUP; + if (!grouping) { + canvasInstance.cancel(); + } + canvasInstance.group({ enabled: !grouping }); + groupObjects(!grouping); + }, + RESET_GROUP: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const grouping = activeControl === ActiveControl.GROUP; + if (!grouping) { + return; + } + resetGroup(); + canvasInstance.group({ enabled: false }); + groupObjects(false); + }, + CANCEL: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (activeControl !== ActiveControl.CURSOR) { + canvasInstance.cancel(); + } + }, + CLOCKWISE_ROTATION: (event: KeyboardEvent | undefined) => { + preventDefault(event); + rotateFrame(Rotation.CLOCKWISE90); + }, + ANTICLOCKWISE_ROTATION: (event: KeyboardEvent | undefined) => { + preventDefault(event); + rotateFrame(Rotation.ANTICLOCKWISE90); + }, + }; + return ( + + diff --git a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx index dadfb5970ac..397bf8d6918 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx @@ -20,6 +20,7 @@ interface Props { startFrame: number; stopFrame: number; frameNumber: number; + inputFrameRef: React.RefObject; onSliderChange(value: SliderValue): void; onInputChange(value: number | undefined): void; onURLIconClick(): void; @@ -30,6 +31,7 @@ function PlayerNavigation(props: Props): JSX.Element { startFrame, stopFrame, frameNumber, + inputFrameRef, onSliderChange, onInputChange, onURLIconClick, @@ -69,6 +71,7 @@ function PlayerNavigation(props: Props): JSX.Element { value={frameNumber || 0} // https://stackoverflow.com/questions/38256332/in-react-whats-the-difference-between-onchange-and-oninput onChange={onInputChange} + ref={inputFrameRef} /> diff --git a/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx b/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx index 2c51efb41ea..a358720303f 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx @@ -8,6 +8,7 @@ import { Row, Col, Layout, + InputNumber, } from 'antd'; import { SliderValue } from 'antd/lib/slider'; @@ -22,6 +23,7 @@ interface Props { saving: boolean; savingStatuses: string[]; frameNumber: number; + inputFrameRef: React.RefObject; startFrame: number; stopFrame: number; undoAction?: string; @@ -42,7 +44,7 @@ interface Props { onRedoClick(): void; } -function AnnotationTopBarComponent(props: Props): JSX.Element { +export default function AnnotationTopBarComponent(props: Props): JSX.Element { const { saving, savingStatuses, @@ -50,6 +52,7 @@ function AnnotationTopBarComponent(props: Props): JSX.Element { redoAction, playing, frameNumber, + inputFrameRef, startFrame, stopFrame, showStatistics, @@ -96,6 +99,7 @@ function AnnotationTopBarComponent(props: Props): JSX.Element { startFrame={startFrame} stopFrame={stopFrame} frameNumber={frameNumber} + inputFrameRef={inputFrameRef} onSliderChange={onSliderChange} onInputChange={onInputChange} onURLIconClick={onURLIconClick} @@ -107,5 +111,3 @@ function AnnotationTopBarComponent(props: Props): JSX.Element { ); } - -export default React.memo(AnnotationTopBarComponent); diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index b696c048d90..91795797980 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -5,18 +5,17 @@ import 'antd/dist/antd.less'; import '../styles.scss'; import React from 'react'; -import { BrowserRouter } from 'react-router-dom'; -import { - Switch, - Route, - Redirect, -} from 'react-router'; +import { Switch, Route, Redirect } from 'react-router'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { GlobalHotKeys, KeyMap, configure } from 'react-hotkeys'; + import { Spin, Layout, notification, } from 'antd'; +import ShorcutsDialog from 'components/shortcuts-dialog/shortcuts-dialog'; import SettingsPageContainer from 'containers/settings-page/settings-page'; import TasksPageContainer from 'containers/tasks-page/tasks-page'; import CreateTaskPageContainer from 'containers/create-task-page/create-task-page'; @@ -30,7 +29,7 @@ import HeaderContainer from 'containers/header/header'; import { NotificationsState } from 'reducers/interfaces'; -type CVATAppProps = { +interface CVATAppProps { loadFormats: () => void; loadUsers: () => void; loadAbout: () => void; @@ -38,6 +37,7 @@ type CVATAppProps = { initPlugins: () => void; resetErrors: () => void; resetMessages: () => void; + switchShortcutsDialog: () => void; userInitialized: boolean; pluginsInitialized: boolean; pluginsFetching: boolean; @@ -52,11 +52,12 @@ type CVATAppProps = { installedTFSegmentation: boolean; notifications: NotificationsState; user: any; -}; +} -export default class CVATApplication extends React.PureComponent { +class CVATApplication extends React.PureComponent { public componentDidMount(): void { const { verifyAuthorized } = this.props; + configure({ ignoreRepeatedEventsWhenKeyHeldDown: false }); verifyAuthorized(); } @@ -190,7 +191,9 @@ export default class CVATApplication extends React.PureComponent { installedAutoAnnotation, installedTFSegmentation, installedTFAnnotation, + switchShortcutsDialog, user, + history, } = this.props; const readyForRender = (userInitialized && user == null) @@ -200,13 +203,50 @@ export default class CVATApplication extends React.PureComponent { const withModels = installedAutoAnnotation || installedTFAnnotation || installedTFSegmentation; + const keyMap = { + SWITCH_SHORTCUTS: { + name: 'Show shortcuts', + description: 'Open/hide the list of available shortcuts', + sequence: 'f1', + action: 'keydown', + }, + OPEN_SETTINGS: { + name: 'Open settings', + description: 'Go to the settings page or go back', + sequence: 'f2', + action: 'keydown', + }, + }; + + const handlers = { + SWITCH_SHORTCUTS: (event: KeyboardEvent | undefined) => { + if (event) { + event.preventDefault(); + } + + switchShortcutsDialog(); + }, + OPEN_SETTINGS: (event: KeyboardEvent | undefined) => { + if (event) { + event.preventDefault(); + } + + if (history.location.pathname.endsWith('settings')) { + history.goBack(); + } else { + history.push('/settings'); + } + }, + }; + if (readyForRender) { if (user) { return ( - - - - + + + + + @@ -219,22 +259,20 @@ export default class CVATApplication extends React.PureComponent { && } - {/* eslint-disable-next-line */} - - - - + + {/* eslint-disable-next-line */} + + + ); } return ( - - - - - - - + + + + + ); } @@ -243,3 +281,5 @@ export default class CVATApplication extends React.PureComponent { ); } } + +export default withRouter(CVATApplication); diff --git a/cvat-ui/src/components/shortcuts-dialog/shortcuts-dialog.tsx b/cvat-ui/src/components/shortcuts-dialog/shortcuts-dialog.tsx new file mode 100644 index 00000000000..c8a0ee7c67a --- /dev/null +++ b/cvat-ui/src/components/shortcuts-dialog/shortcuts-dialog.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { getApplicationKeyMap } from 'react-hotkeys'; +import { Modal, Table } from 'antd'; +import { connect } from 'react-redux'; + +import { shortcutsActions } from 'actions/shortcuts-actions'; +import { CombinedState } from 'reducers/interfaces'; + +interface StateToProps { + visible: boolean; +} + +interface DispatchToProps { + switchShortcutsDialog(): void; +} + +function mapStateToProps(state: CombinedState): StateToProps { + const { + shortcuts: { + visibleShortcutsHelp: visible, + }, + } = state; + + return { + visible, + }; +} + +function mapDispatchToProps(dispatch: any): DispatchToProps { + return { + switchShortcutsDialog(): void { + dispatch(shortcutsActions.switchShortcutsDialog()); + }, + }; +} + +function ShorcutsDialog(props: StateToProps & DispatchToProps): JSX.Element | null { + const { visible, switchShortcutsDialog } = props; + const keyMap = getApplicationKeyMap(); + + const splitToRows = (data: string[]): JSX.Element[] => ( + data.map((item: string, id: number): JSX.Element => ( + // eslint-disable-next-line react/no-array-index-key + + {item} +
+
+ )) + ); + + const columns = [{ + title: 'Name', + dataIndex: 'name', + key: 'name', + }, { + title: 'Shortcut', + dataIndex: 'shortcut', + key: 'shortcut', + render: splitToRows, + }, { + title: 'Action', + dataIndex: 'action', + key: 'action', + render: splitToRows, + }, { + title: 'Description', + dataIndex: 'description', + key: 'description', + }]; + + const dataSource = Object.keys(keyMap).map((key: string, id: number) => ({ + key: id, + name: keyMap[key].name || key, + description: keyMap[key].description || '', + shortcut: keyMap[key].sequences.map((value) => value.sequence), + action: keyMap[key].sequences.map((value) => value.action || 'keydown'), + })); + + return ( + + + + ); +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ShorcutsDialog); diff --git a/cvat-ui/src/containers/annotation-page/annotation-page.tsx b/cvat-ui/src/containers/annotation-page/annotation-page.tsx index d40cb2c1e91..c66a93049a2 100644 --- a/cvat-ui/src/containers/annotation-page/annotation-page.tsx +++ b/cvat-ui/src/containers/annotation-page/annotation-page.tsx @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: MIT -import React from 'react'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { RouteComponentProps } from 'react-router'; @@ -80,15 +79,10 @@ function mapDispatchToProps(dispatch: any, own: OwnProps): DispatchToProps { }; } -function AnnotationPageContainer(props: StateToProps & DispatchToProps): JSX.Element { - return ( - - ); -} export default withRouter( connect( mapStateToProps, mapDispatchToProps, - )(AnnotationPageContainer), + )(AnnotationPageComponent), ); 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 f2185a6d4e2..0625bece913 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 @@ -2,11 +2,9 @@ // // SPDX-License-Identifier: MIT -import React from 'react'; import { connect } from 'react-redux'; import CanvasWrapperComponent from 'components/annotation-page/standard-workspace/canvas-wrapper'; - import { confirmCanvasReady, dragCanvas, @@ -28,6 +26,14 @@ import { addZLayer, switchZLayer, } from 'actions/annotation-actions'; +import { + switchGrid, + changeGridColor, + changeGridOpacity, + changeBrightnessLevel, + changeContrastLevel, + changeSaturationLevel, +} from 'actions/settings-actions'; import { ColorBy, GridColor, @@ -86,6 +92,12 @@ interface DispatchToProps { onUpdateContextMenu(visible: boolean, left: number, top: number): void; onAddZLayer(): void; onSwitchZLayer(cur: number): void; + onChangeBrightnessLevel(level: number): void; + onChangeContrastLevel(level: number): void; + onChangeSaturationLevel(level: number): void; + onChangeGridOpacity(opacity: number): void; + onChangeGridColor(color: GridColor): void; + onSwitchGrid(enabled: boolean): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -233,16 +245,28 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { onSwitchZLayer(cur: number): void { dispatch(switchZLayer(cur)); }, + onChangeBrightnessLevel(level: number): void { + dispatch(changeBrightnessLevel(level)); + }, + onChangeContrastLevel(level: number): void { + dispatch(changeContrastLevel(level)); + }, + onChangeSaturationLevel(level: number): void { + dispatch(changeSaturationLevel(level)); + }, + onChangeGridOpacity(opacity: number): void { + dispatch(changeGridOpacity(opacity)); + }, + onChangeGridColor(color: GridColor): void { + dispatch(changeGridColor(color)); + }, + onSwitchGrid(enabled: boolean): void { + dispatch(switchGrid(enabled)); + }, }; } -function CanvasWrapperContainer(props: StateToProps & DispatchToProps): JSX.Element { - return ( - - ); -} - export default connect( mapStateToProps, mapDispatchToProps, -)(CanvasWrapperContainer); +)(CanvasWrapperComponent); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx index 177647892f4..1f0b1f31515 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: MIT -import React from 'react'; import { connect } from 'react-redux'; import { Canvas } from 'cvat-canvas'; @@ -12,6 +11,9 @@ import { groupObjects, splitTrack, rotateCurrentFrame, + repeatDrawShapeAsync, + pasteShapeAsync, + resetAnnotationsGroup, } from 'actions/annotation-actions'; import ControlsSideBarComponent from 'components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar'; import { @@ -31,6 +33,9 @@ interface DispatchToProps { groupObjects(enabled: boolean): void; splitTrack(enabled: boolean): void; rotateFrame(angle: Rotation): void; + resetGroup(): void; + repeatDrawShape(): void; + pasteShape(): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -69,16 +74,19 @@ function dispatchToProps(dispatch: any): DispatchToProps { rotateFrame(rotation: Rotation): void { dispatch(rotateCurrentFrame(rotation)); }, + repeatDrawShape(): void { + dispatch(repeatDrawShapeAsync()); + }, + pasteShape(): void { + dispatch(pasteShapeAsync()); + }, + resetGroup(): void { + dispatch(resetAnnotationsGroup()); + }, }; } -function ControlsSideBarContainer(props: StateToProps & DispatchToProps): JSX.Element { - return ( - - ); -} - export default connect( mapStateToProps, dispatchToProps, -)(ControlsSideBarContainer); +)(ControlsSideBarComponent); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx index ef2783cb040..2e70ed1bb03 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx @@ -115,8 +115,11 @@ class LabelItemContainer extends React.PureComponent { let statesLocked = true; ownObjectStates.forEach((objectState: any) => { - statesHidden = statesHidden && objectState.hidden; - statesLocked = statesLocked && objectState.lock; + const { lock } = objectState; + if (!lock) { + statesHidden = statesHidden && objectState.hidden; + statesLocked = statesLocked && objectState.lock; + } }); return { diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/labels-list.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/labels-list.tsx index bffadae0d31..3494ba976b6 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/labels-list.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/labels-list.tsx @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: MIT -import React from 'react'; import { connect } from 'react-redux'; import LabelsListComponent from 'components/annotation-page/standard-workspace/objects-side-bar/labels-list'; @@ -29,12 +28,6 @@ function mapStateToProps(state: CombinedState): StateToProps { }; } -function LabelsListContainer(props: StateToProps): JSX.Element { - return ( - - ); -} - export default connect( mapStateToProps, -)(LabelsListContainer); +)(LabelsListComponent); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx index 6ced57029d2..9c65ee79343 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx @@ -20,6 +20,7 @@ import { copyShape as copyShapeAction, activateObject as activateObjectAction, propagateObject as propagateObjectAction, + pasteShapeAsync, } from 'actions/annotation-actions'; import ObjectStateItemComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item'; @@ -134,6 +135,7 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { }, copyShape(objectState: any): void { dispatch(copyShapeAction(objectState)); + dispatch(pasteShapeAsync()); }, propagateObject(objectState: any): void { dispatch(propagateObjectAction(objectState)); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx index 0c22f1f5faa..8bc18e0f6d6 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { connect } from 'react-redux'; +import { GlobalHotKeys, KeyMap } from 'react-hotkeys'; import { SelectValue } from 'antd/lib/select'; @@ -11,13 +12,18 @@ import ObjectsListComponent from 'components/annotation-page/standard-workspace/ import { updateAnnotationsAsync, fetchAnnotationsAsync, + removeObjectAsync, + changeFrameAsync, changeAnnotationsFilters as changeAnnotationsFiltersAction, collapseObjectItems, + copyShape as copyShapeAction, + propagateObject as propagateObjectAction, } from 'actions/annotation-actions'; import { CombinedState, StatesOrdering, + ObjectType, } from 'reducers/interfaces'; interface StateToProps { @@ -29,6 +35,9 @@ interface StateToProps { statesCollapsed: boolean; objectStates: any[]; annotationsFilters: string[]; + activatedStateID: number | null; + minZLayer: number; + maxZLayer: number; annotationsFiltersHistory: string[]; } @@ -36,6 +45,10 @@ interface DispatchToProps { updateAnnotations(sessionInstance: any, frameNumber: number, states: any[]): void; changeAnnotationsFilters(sessionInstance: any, filters: string[]): void; collapseStates(states: any[], value: boolean): void; + removeObject: (sessionInstance: any, objectState: any, force: boolean) => void; + copyShape: (objectState: any) => void; + propagateObject: (objectState: any) => void; + changeFrame(frame: number): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -46,6 +59,11 @@ function mapStateToProps(state: CombinedState): StateToProps { filters: annotationsFilters, filtersHistory: annotationsFiltersHistory, collapsed, + activatedStateID, + zLayer: { + min: minZLayer, + max: maxZLayer, + }, }, job: { instance: jobInstance, @@ -64,9 +82,11 @@ function mapStateToProps(state: CombinedState): StateToProps { let statesCollapsed = true; objectStates.forEach((objectState: any) => { - const { clientID } = objectState; - statesHidden = statesHidden && objectState.hidden; - statesLocked = statesLocked && objectState.lock; + const { clientID, lock } = objectState; + if (!lock) { + statesHidden = statesHidden && objectState.hidden; + statesLocked = statesLocked && objectState.lock; + } const stateCollapsed = clientID in collapsed ? collapsed[clientID] : true; statesCollapsed = statesCollapsed && stateCollapsed; }); @@ -80,6 +100,9 @@ function mapStateToProps(state: CombinedState): StateToProps { frameNumber, jobInstance, annotationsFilters, + activatedStateID, + minZLayer, + maxZLayer, annotationsFiltersHistory, }; } @@ -99,6 +122,18 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { dispatch(changeAnnotationsFiltersAction(filters)); dispatch(fetchAnnotationsAsync(sessionInstance)); }, + removeObject(sessionInstance: any, objectState: any, force: boolean): void { + dispatch(removeObjectAsync(sessionInstance, objectState, force)); + }, + copyShape(objectState: any): void { + dispatch(copyShapeAction(objectState)); + }, + propagateObject(objectState: any): void { + dispatch(propagateObjectAction(objectState)); + }, + changeFrame(frame: number): void { + dispatch(changeFrameAsync(frame)); + }, }; } @@ -226,6 +261,19 @@ class ObjectsListContainer extends React.PureComponent { public render(): JSX.Element { const { annotationsFilters, + statesHidden, + statesLocked, + activatedStateID, + objectStates, + frameNumber, + jobInstance, + updateAnnotations, + removeObject, + copyShape, + propagateObject, + changeFrame, + maxZLayer, + minZLayer, annotationsFiltersHistory, } = this.props; const { @@ -233,22 +281,241 @@ class ObjectsListContainer extends React.PureComponent { statesOrdering, } = this.state; + const keyMap = { + SWITCH_ALL_LOCK: { + name: 'Lock/unlock all objects', + description: 'Change locked state for all objects in the side bar', + sequence: 't+l', + action: 'keydown', + }, + SWITCH_LOCK: { + name: 'Lock/unlock an object', + description: 'Change locked state for an active object', + sequence: 'l', + action: 'keydown', + }, + SWITCH_ALL_HIDDEN: { + name: 'Hide/show all objects', + description: 'Change hidden state for objects in the side bar', + sequence: 't+h', + action: 'keydown', + }, + SWITCH_HIDDEN: { + name: 'Hide/show an object', + description: 'Change hidden state for an active object', + sequence: 'h', + action: 'keydown', + }, + SWITCH_OCCLUDED: { + name: 'Switch occluded', + description: 'Change occluded property for an active object', + sequences: ['q', '/'], + action: 'keydown', + }, + SWITCH_KEYFRAME: { + name: 'Switch keyframe', + description: 'Change keyframe property for an active track', + sequence: 'k', + action: 'keydown', + }, + SWITCH_OUTSIDE: { + name: 'Switch outside', + description: 'Change outside property for an active track', + sequence: 'o', + action: 'keydown', + }, + DELETE_OBJECT: { + name: 'Delete object', + description: 'Delete an active object. Use shift to force delete of locked objects', + sequences: ['del', 'shift+del'], + action: 'keydown', + }, + TO_BACKGROUND: { + name: 'To background', + description: 'Put an active object "farther" from the user (decrease z axis value)', + sequences: ['-', '_'], + action: 'keydown', + }, + TO_FOREGROUND: { + name: 'To foreground', + description: 'Put an active object "closer" to the user (increase z axis value)', + sequences: ['+', '='], + action: 'keydown', + }, + COPY_SHAPE: { + name: 'Copy shape', + description: 'Copy shape to CVAT internal clipboard', + sequence: 'ctrl+c', + action: 'keydown', + }, + PROPAGATE_OBJECT: { + name: 'Propagate object', + description: 'Make a copy of the object on the following frames', + sequence: 'ctrl+b', + action: 'keydown', + }, + NEXT_KEY_FRAME: { + name: 'Next keyframe', + description: 'Go to the next keyframe of an active track', + sequence: 'r', + action: 'keydown', + }, + PREV_KEY_FRAME: { + name: 'Previous keyframe', + description: 'Go to the previous keyframe of an active track', + sequence: 'e', + action: 'keydown', + }, + }; + + const preventDefault = (event: KeyboardEvent | undefined): void => { + if (event) { + event.preventDefault(); + } + }; + + const activatedStated = (): any | null => { + if (activatedStateID !== null) { + const [state] = objectStates + .filter((objectState: any): boolean => ( + objectState.clientID === activatedStateID + )); + + return state || null; + } + + return null; + }; + + const handlers = { + SWITCH_ALL_LOCK: (event: KeyboardEvent | undefined) => { + preventDefault(event); + this.lockAllStates(!statesLocked); + }, + SWITCH_LOCK: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state) { + state.lock = !state.lock; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + SWITCH_ALL_HIDDEN: (event: KeyboardEvent | undefined) => { + preventDefault(event); + this.hideAllStates(!statesHidden); + }, + SWITCH_HIDDEN: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state) { + state.hidden = !state.hidden; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + SWITCH_OCCLUDED: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType !== ObjectType.TAG) { + state.occluded = !state.occluded; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + SWITCH_KEYFRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType === ObjectType.TRACK) { + state.keyframe = !state.keyframe; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + SWITCH_OUTSIDE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType === ObjectType.TRACK) { + state.outside = !state.outside; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + DELETE_OBJECT: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state) { + removeObject(jobInstance, state, event ? event.shiftKey : false); + } + }, + TO_BACKGROUND: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType !== ObjectType.TAG) { + state.zOrder = minZLayer - 1; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + TO_FOREGROUND: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType !== ObjectType.TAG) { + state.zOrder = maxZLayer + 1; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + COPY_SHAPE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType !== ObjectType.TAG) { + copyShape(state); + } + }, + PROPAGATE_OBJECT: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType !== ObjectType.TAG) { + propagateObject(state); + } + }, + NEXT_KEY_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType === ObjectType.TRACK) { + const frame = typeof (state.keyframes.next) === 'number' + ? state.keyframes.next : null; + if (frame !== null) { + changeFrame(frame); + } + } + }, + PREV_KEY_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType === ObjectType.TRACK) { + const frame = typeof (state.keyframes.prev) === 'number' + ? state.keyframes.prev : null; + if (frame !== null) { + changeFrame(frame); + } + } + }, + }; + return ( - + <> + + + ); } } diff --git a/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx b/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx index c5758b561fe..8bcc1f59f87 100644 --- a/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx @@ -8,7 +8,9 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router'; import { RouteComponentProps } from 'react-router-dom'; +import { GlobalHotKeys, KeyMap } from 'react-hotkeys'; +import { InputNumber } from 'antd'; import { SliderValue } from 'antd/lib/slider'; import { @@ -19,6 +21,7 @@ import { showStatistics as showStatisticsAction, undoActionAsync, redoActionAsync, + searchAnnotationsAsync, } from 'actions/annotation-actions'; import AnnotationTopBarComponent from 'components/annotation-page/top-bar/top-bar'; @@ -47,6 +50,7 @@ interface DispatchToProps { showStatistics(sessionInstance: any): void; undo(sessionInstance: any, frameNumber: any): void; redo(sessionInstance: any, frameNumber: any): void; + searchAnnotations(sessionInstance: any, frameFrom: any, frameTo: any): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -123,14 +127,23 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { redo(sessionInstance: any, frameNumber: any): void { dispatch(redoActionAsync(sessionInstance, frameNumber)); }, + searchAnnotations(sessionInstance: any, frameFrom: any, frameTo: any): void { + dispatch(searchAnnotationsAsync(sessionInstance, frameFrom, frameTo)); + }, }; } type Props = StateToProps & DispatchToProps & RouteComponentProps; class AnnotationTopBarContainer extends React.PureComponent { + private inputFrameRef: React.RefObject; private autoSaveInterval: number | undefined; private unblock: any; + constructor(props: Props) { + super(props); + this.inputFrameRef = React.createRef(); + } + public componentDidMount(): void { const { autoSave, @@ -421,6 +434,7 @@ class AnnotationTopBarContainer extends React.PureComponent { playing, saving, savingStatuses, + jobInstance, jobInstance: { startFrame, stopFrame, @@ -428,33 +442,179 @@ class AnnotationTopBarContainer extends React.PureComponent { frameNumber, undoAction, redoAction, + searchAnnotations, + canvasIsReady, } = this.props; + const preventDefault = (event: KeyboardEvent | undefined): void => { + if (event) { + event.preventDefault(); + } + }; + + const keyMap = { + SAVE_JOB: { + name: 'Save the job', + description: 'Send all changes of annotations to the server', + sequence: 'ctrl+s', + action: 'keydown', + }, + UNDO: { + name: 'Undo action', + description: 'Cancel the latest action related with objects', + sequence: 'ctrl+z', + action: 'keydown', + }, + REDO: { + name: 'Redo action', + description: 'Cancel undo action', + sequences: ['ctrl+shift+z', 'ctrl+y'], + action: 'keydown', + }, + NEXT_FRAME: { + name: 'Next frame', + description: 'Go to the next frame', + sequence: 'f', + action: 'keydown', + }, + PREV_FRAME: { + name: 'Previous frame', + description: 'Go to the previous frame', + sequence: 'd', + action: 'keydown', + }, + FORWARD_FRAME: { + name: 'Forward frame', + description: 'Go forward with a step', + sequence: 'v', + action: 'keydown', + }, + BACKWARD_FRAME: { + name: 'Backward frame', + description: 'Go backward with a step', + sequence: 'c', + action: 'keydown', + }, + SEARCH_FORWARD: { + name: 'Search forward', + description: 'Search the next frame that satisfies to the filters', + sequence: 'right', + action: 'keydown', + }, + SEARCH_BACKWARD: { + name: 'Search backward', + description: 'Search the previous frame that satisfies to the filters', + sequence: 'left', + action: 'keydown', + }, + PLAY_PAUSE: { + name: 'Play/pause', + description: 'Start/stop automatic changing frames', + sequence: 'space', + action: 'keydown', + }, + FOCUS_INPUT_FRAME: { + name: 'Focus input frame', + description: 'Focus on the element to change the current frame', + sequences: ['`', '~'], + action: 'keydown', + }, + }; + + const handlers = { + UNDO: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (undoAction) { + this.undo(); + } + }, + REDO: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (redoAction) { + this.redo(); + } + }, + SAVE_JOB: (event: KeyboardEvent | undefined) => { + preventDefault(event); + this.onSaveAnnotation(); + }, + NEXT_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (canvasIsReady) { + this.onNextFrame(); + } + }, + PREV_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (canvasIsReady) { + this.onPrevFrame(); + } + }, + FORWARD_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (canvasIsReady) { + this.onForward(); + } + }, + BACKWARD_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (canvasIsReady) { + this.onBackward(); + } + }, + SEARCH_FORWARD: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (frameNumber + 1 <= stopFrame && canvasIsReady) { + searchAnnotations(jobInstance, frameNumber + 1, stopFrame); + } + }, + SEARCH_BACKWARD: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (frameNumber - 1 >= startFrame && canvasIsReady) { + searchAnnotations(jobInstance, frameNumber - 1, startFrame); + } + }, + PLAY_PAUSE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + this.onSwitchPlay(); + }, + FOCUS_INPUT_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (this.inputFrameRef.current) { + this.inputFrameRef.current.focus(); + } + }, + }; + return ( - + <> + + + ); } } diff --git a/cvat-ui/src/index.tsx b/cvat-ui/src/index.tsx index 9dd8e219092..468b53722de 100644 --- a/cvat-ui/src/index.tsx +++ b/cvat-ui/src/index.tsx @@ -5,6 +5,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { connect, Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; import CVATApplication from './components/cvat-app'; @@ -16,6 +17,7 @@ import { getFormatsAsync } from './actions/formats-actions'; import { checkPluginsAsync } from './actions/plugins-actions'; import { getUsersAsync } from './actions/users-actions'; import { getAboutAsync } from './actions/about-actions'; +import { shortcutsActions } from './actions/shortcuts-actions'; import { resetErrors, resetMessages, @@ -54,6 +56,7 @@ interface DispatchToProps { initPlugins: () => void; resetErrors: () => void; resetMessages: () => void; + switchShortcutsDialog: () => void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -90,24 +93,21 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { loadAbout: (): void => dispatch(getAboutAsync()), resetErrors: (): void => dispatch(resetErrors()), resetMessages: (): void => dispatch(resetMessages()), + switchShortcutsDialog: (): void => dispatch(shortcutsActions.switchShortcutsDialog()), }; } -function reduxAppWrapper(props: StateToProps & DispatchToProps): JSX.Element { - return ( - - ); -} - const ReduxAppWrapper = connect( mapStateToProps, mapDispatchToProps, -)(reduxAppWrapper); +)(CVATApplication); ReactDOM.render( ( - + + + ), document.getElementById('root'), diff --git a/cvat-ui/src/reducers/annotation-reducer.ts b/cvat-ui/src/reducers/annotation-reducer.ts index ce857147ff2..d5de5f6009c 100644 --- a/cvat-ui/src/reducers/annotation-reducer.ts +++ b/cvat-ui/src/reducers/annotation-reducer.ts @@ -61,7 +61,10 @@ const defaultState: AnnotationState = { collapsed: {}, states: [], filters: [], - filtersHistory: JSON.parse(window.localStorage.getItem('filtersHistory') as string) || [], + filtersHistory: JSON.parse( + window.localStorage.getItem('filtersHistory') || '[]', + ), + resetGroupFlag: false, history: { undo: [], redo: [], @@ -419,6 +422,21 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } + case AnnotationActionTypes.REPEAT_DRAW_SHAPE: { + const { activeControl } = action.payload; + + return { + ...state, + annotations: { + ...state.annotations, + activatedStateID: null, + }, + canvas: { + ...state.canvas, + activeControl, + }, + }; + } case AnnotationActionTypes.MERGE_OBJECTS: { const { enabled } = action.payload; const activeControl = enabled @@ -554,6 +572,24 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } + case AnnotationActionTypes.RESET_ANNOTATIONS_GROUP: { + return { + ...state, + annotations: { + ...state.annotations, + resetGroupFlag: true, + }, + }; + } + case AnnotationActionTypes.GROUP_ANNOTATIONS: { + return { + ...state, + annotations: { + ...state.annotations, + resetGroupFlag: false, + }, + }; + } case AnnotationActionTypes.GROUP_ANNOTATIONS_SUCCESS: { const { states, @@ -662,25 +698,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } - case AnnotationActionTypes.COPY_SHAPE: { - const { - objectState, - } = action.payload; - - state.canvas.instance.cancel(); - state.canvas.instance.draw({ - enabled: true, - initialState: objectState, - }); - - let activeControl = ActiveControl.DRAW_RECTANGLE; - if (objectState.shapeType === ShapeType.POINTS) { - activeControl = ActiveControl.DRAW_POINTS; - } else if (objectState.shapeType === ShapeType.POLYGON) { - activeControl = ActiveControl.DRAW_POLYGON; - } else if (objectState.shapeType === ShapeType.POLYLINE) { - activeControl = ActiveControl.DRAW_POLYLINE; - } + case AnnotationActionTypes.PASTE_SHAPE: { + const { activeControl } = action.payload; return { ...state, @@ -694,6 +713,19 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } + case AnnotationActionTypes.COPY_SHAPE: { + const { + objectState, + } = action.payload; + + return { + ...state, + drawing: { + ...state.drawing, + activeInitialState: objectState, + }, + }; + } case AnnotationActionTypes.EDIT_SHAPE: { const { enabled } = action.payload; const activeControl = enabled diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index f6dec362ca1..74e36d86c56 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -229,6 +229,7 @@ export interface NotificationsState { fetchingAnnotations: null | ErrorState; undo: null | ErrorState; redo: null | ErrorState; + search: null | ErrorState; }; [index: string]: any; @@ -329,6 +330,7 @@ export interface AnnotationState { activeNumOfPoints?: number; activeLabelID: number; activeObjectType: ObjectType; + activeInitialState?: any; }; annotations: { selectedStatesID: number[]; @@ -337,6 +339,7 @@ export interface AnnotationState { states: any[]; filters: string[]; filtersHistory: string[]; + resetGroupFlag: boolean; history: { undo: string[]; redo: string[]; @@ -423,6 +426,10 @@ export interface SettingsState { player: PlayerSettingsState; } +export interface ShortcutsState { + visibleShortcutsHelp: boolean; +} + export interface CombinedState { auth: AuthState; tasks: TasksState; @@ -435,4 +442,5 @@ export interface CombinedState { notifications: NotificationsState; annotation: AnnotationState; settings: SettingsState; + shortcuts: ShortcutsState; } diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index c1111f38934..9ca0240d680 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -73,6 +73,7 @@ const defaultState: NotificationsState = { fetchingAnnotations: null, undo: null, redo: null, + search: null, }, }, messages: { @@ -750,6 +751,21 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case AnnotationActionTypes.SEARCH_ANNOTATIONS_FAILED: { + return { + ...state, + errors: { + ...state.errors, + annotation: { + ...state.errors.annotation, + search: { + message: 'Could not execute search annotations', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } case NotificationsActionType.RESET_ERRORS: { return { ...state, diff --git a/cvat-ui/src/reducers/root-reducer.ts b/cvat-ui/src/reducers/root-reducer.ts index 6e65dcf44e9..337bfbc5345 100644 --- a/cvat-ui/src/reducers/root-reducer.ts +++ b/cvat-ui/src/reducers/root-reducer.ts @@ -14,6 +14,7 @@ import modelsReducer from './models-reducer'; import notificationsReducer from './notifications-reducer'; import annotationReducer from './annotation-reducer'; import settingsReducer from './settings-reducer'; +import shortcutsReducer from './shortcuts-reducer'; export default function createRootReducer(): Reducer { return combineReducers({ @@ -28,5 +29,6 @@ export default function createRootReducer(): Reducer { notifications: notificationsReducer, annotation: annotationReducer, settings: settingsReducer, + shortcuts: shortcutsReducer, }); } diff --git a/cvat-ui/src/reducers/shortcuts-reducer.ts b/cvat-ui/src/reducers/shortcuts-reducer.ts new file mode 100644 index 00000000000..92397b64b01 --- /dev/null +++ b/cvat-ui/src/reducers/shortcuts-reducer.ts @@ -0,0 +1,20 @@ +import { ShortcutsActions, ShortcutsActionsTypes } from 'actions/shortcuts-actions'; +import { ShortcutsState } from './interfaces'; + +const defaultState: ShortcutsState = { + visibleShortcutsHelp: false, +}; + +export default (state = defaultState, action: ShortcutsActions): ShortcutsState => { + switch (action.type) { + case ShortcutsActionsTypes.SWITCH_SHORTCUT_DIALOG: { + return { + ...state, + visibleShortcutsHelp: !state.visibleShortcutsHelp, + }; + } + default: { + return state; + } + } +};