diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e9db7a..0b63377c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Datumaro] CLI command for dataset equality comparison () - [Datumaro] Merging of datasets with different labels () - Add FBRS interactive segmentation serverless function () +- Ability to change default behaviour of previous/next buttons of a player. +It supports regular navigation, searching a frame according to annotations +filters and searching the nearest frame without any annotations () - MacOS users notes in CONTRIBUTING.md ### Changed diff --git a/cvat-core/src/annotations-collection.js b/cvat-core/src/annotations-collection.js index ed338f6b..a4aeae9b 100644 --- a/cvat-core/src/annotations-collection.js +++ b/cvat-core/src/annotations-collection.js @@ -848,6 +848,43 @@ }; } + searchEmpty(frameFrom, frameTo) { + const sign = Math.sign(frameTo - frameFrom); + const predicate = sign > 0 + ? (frame) => frame <= frameTo + : (frame) => frame >= frameTo; + const update = sign > 0 + ? (frame) => frame + 1 + : (frame) => frame - 1; + for (let frame = frameFrom; predicate(frame); frame = update(frame)) { + if (frame in this.shapes && this.shapes[frame].some((shape) => !shape.removed)) { + continue; + } + if (frame in this.tags && this.tags[frame].some((tag) => !tag.removed)) { + continue; + } + const filteredTracks = this.tracks.filter((track) => !track.removed); + let found = false; + for (const track of filteredTracks) { + const keyframes = track.boundedKeyframes(frame); + const { prev, first } = keyframes; + const last = prev === null ? first : prev; + const lastShape = track.shapes[last]; + const isKeyfame = frame in track.shapes; + if (first <= frame && (!lastShape.outside || isKeyfame)) { + found = true; + break; + } + } + + if (found) continue; + + return frame; + } + + return null; + } + search(filters, frameFrom, frameTo) { const [groups, query] = this.annotationsFilter.toJSONQuery(filters); const sign = Math.sign(frameTo - frameFrom); diff --git a/cvat-core/src/annotations-objects.js b/cvat-core/src/annotations-objects.js index 752670fe..2e60db0e 100644 --- a/cvat-core/src/annotations-objects.js +++ b/cvat-core/src/annotations-objects.js @@ -932,7 +932,9 @@ }, [this.clientID], frame); } - _appendShapeActionToHistory(actionType, frame, undoShape, redoShape, undoSource, redoSource) { + _appendShapeActionToHistory( + actionType, frame, undoShape, redoShape, undoSource, redoSource, + ) { this.history.do(actionType, () => { if (!undoShape) { delete this.shapes[frame]; diff --git a/cvat-core/src/annotations.js b/cvat-core/src/annotations.js index 63a316ac..66366c25 100644 --- a/cvat-core/src/annotations.js +++ b/cvat-core/src/annotations.js @@ -122,6 +122,19 @@ ); } + function searchEmptyFrame(session, frameFrom, frameTo) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); + + if (cache.has(session)) { + return cache.get(session).collection.searchEmpty(frameFrom, frameTo); + } + + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); + } + function mergeAnnotations(session, objectStates) { const sessionType = session instanceof Task ? 'task' : 'job'; const cache = getCache(sessionType); @@ -373,6 +386,7 @@ hasUnsavedChanges, mergeAnnotations, searchAnnotations, + searchEmptyFrame, splitAnnotations, groupAnnotations, clearAnnotations, diff --git a/cvat-core/src/api-implementation.js b/cvat-core/src/api-implementation.js index c2a429ab..ddbfcbd8 100644 --- a/cvat-core/src/api-implementation.js +++ b/cvat-core/src/api-implementation.js @@ -95,7 +95,9 @@ await serverProxy.server.logout(); }; - cvat.server.changePassword.implementation = async (oldPassword, newPassword1, newPassword2) => { + cvat.server.changePassword.implementation = async ( + oldPassword, newPassword1, newPassword2, + ) => { await serverProxy.server.changePassword(oldPassword, newPassword1, newPassword2); }; @@ -103,7 +105,9 @@ await serverProxy.server.requestPasswordReset(email); }; - cvat.server.resetPassword.implementation = async(newPassword1, newPassword2, uid, token) => { + cvat.server.resetPassword.implementation = async ( + newPassword1, newPassword2, uid, token, + ) => { await serverProxy.server.resetPassword(newPassword1, newPassword2, uid, token); }; diff --git a/cvat-core/src/api.js b/cvat-core/src/api.js index 68cb46bd..2510d96c 100644 --- a/cvat-core/src/api.js +++ b/cvat-core/src/api.js @@ -206,7 +206,9 @@ function build() { */ async changePassword(oldPassword, newPassword1, newPassword2) { const result = await PluginRegistry - .apiWrapper(cvat.server.changePassword, oldPassword, newPassword1, newPassword2); + .apiWrapper( + cvat.server.changePassword, oldPassword, newPassword1, newPassword2, + ); return result; }, /** diff --git a/cvat-core/src/enums.js b/cvat-core/src/enums.js index c7b6c595..91420f98 100644 --- a/cvat-core/src/enums.js +++ b/cvat-core/src/enums.js @@ -46,7 +46,7 @@ * @property {string} UNKNOWN 'unknown' * @readonly */ - const RQStatus = Object.freeze({ + const RQStatus = Object.freeze({ QUEUED: 'queued', STARTED: 'started', FINISHED: 'finished', @@ -134,8 +134,8 @@ * @readonly */ const Source = Object.freeze({ - MANUAL:'manual', - AUTO:'auto', + MANUAL: 'manual', + AUTO: 'auto', }); /** diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js index 8d028725..320c755d 100644 --- a/cvat-core/src/session.js +++ b/cvat-core/src/session.js @@ -76,6 +76,13 @@ return result; }, + async searchEmpty(frameFrom, frameTo) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.searchEmpty, + frameFrom, frameTo); + return result; + }, + async select(objectStates, x, y) { const result = await PluginRegistry .apiWrapper.call(this, @@ -347,6 +354,18 @@ * @instance * @async */ + /** + * Find the nearest empty frame without any annotations + * @method searchEmpty + * @memberof Session.annotations + * @param {integer} from lower bound of a search + * @param {integer} to upper bound of a search + * @returns {integer|null} a frame that contains objects according to the filter + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + * @async + */ /** * Select shape under a cursor by using minimal distance * between a cursor and a shape edge or a shape point @@ -746,6 +765,7 @@ group: Object.getPrototypeOf(this).annotations.group.bind(this), clear: Object.getPrototypeOf(this).annotations.clear.bind(this), search: Object.getPrototypeOf(this).annotations.search.bind(this), + searchEmpty: Object.getPrototypeOf(this).annotations.searchEmpty.bind(this), upload: Object.getPrototypeOf(this).annotations.upload.bind(this), select: Object.getPrototypeOf(this).annotations.select.bind(this), import: Object.getPrototypeOf(this).annotations.import.bind(this), @@ -1318,6 +1338,7 @@ group: Object.getPrototypeOf(this).annotations.group.bind(this), clear: Object.getPrototypeOf(this).annotations.clear.bind(this), search: Object.getPrototypeOf(this).annotations.search.bind(this), + searchEmpty: Object.getPrototypeOf(this).annotations.searchEmpty.bind(this), upload: Object.getPrototypeOf(this).annotations.upload.bind(this), select: Object.getPrototypeOf(this).annotations.select.bind(this), import: Object.getPrototypeOf(this).annotations.import.bind(this), @@ -1411,6 +1432,7 @@ saveAnnotations, hasUnsavedChanges, searchAnnotations, + searchEmptyFrame, mergeAnnotations, splitAnnotations, groupAnnotations, @@ -1537,6 +1559,29 @@ return result; }; + Job.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) { + if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { + throw new ArgumentError( + 'The start and end frames both must be an integer', + ); + } + + if (frameFrom < this.startFrame || frameFrom > this.stopFrame) { + throw new ArgumentError( + 'The start frame is out of the job', + ); + } + + if (frameTo < this.startFrame || frameTo > this.stopFrame) { + throw new ArgumentError( + 'The stop frame is out of the job', + ); + } + + const result = searchEmptyFrame(this, frameFrom, frameTo); + return result; + }; + Job.prototype.annotations.save.implementation = async function (onUpdate) { const result = await saveAnnotations(this, onUpdate); return result; @@ -1807,6 +1852,29 @@ return result; }; + Task.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) { + if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { + throw new ArgumentError( + 'The start and end frames both must be an integer', + ); + } + + if (frameFrom < 0 || frameFrom >= this.size) { + throw new ArgumentError( + 'The start frame is out of the task', + ); + } + + if (frameTo < 0 || frameTo >= this.size) { + throw new ArgumentError( + 'The stop frame is out of the task', + ); + } + + const result = searchEmptyFrame(this, frameFrom, frameTo); + return result; + }; + Task.prototype.annotations.save.implementation = async function (onUpdate) { const result = await saveAnnotations(this, onUpdate); return result; diff --git a/cvat-core/src/user.js b/cvat-core/src/user.js index 555ea83d..f122d20b 100644 --- a/cvat-core/src/user.js +++ b/cvat-core/src/user.js @@ -152,7 +152,7 @@ * @readonly * @instance */ - get: () => !data.email_verification_required, + get: () => !data.email_verification_required, }, })); } diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index eec8d5c9..2a64cbd0 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.9.7", + "version": "1.9.8", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/cvat-ui/package.json b/cvat-ui/package.json index b15924fd..3bad59a7 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.9.7", + "version": "1.9.8", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 0e4edbdf..1e667ee1 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -186,6 +186,7 @@ export enum AnnotationActionTypes { SWITCH_Z_LAYER = 'SWITCH_Z_LAYER', ADD_Z_LAYER = 'ADD_Z_LAYER', SEARCH_ANNOTATIONS_FAILED = 'SEARCH_ANNOTATIONS_FAILED', + SEARCH_EMPTY_FRAME_FAILED = 'SEARCH_EMPTY_FRAME_FAILED', CHANGE_WORKSPACE = 'CHANGE_WORKSPACE', SAVE_LOGS_SUCCESS = 'SAVE_LOGS_SUCCESS', SAVE_LOGS_FAILED = 'SAVE_LOGS_FAILED', @@ -1331,6 +1332,28 @@ export function searchAnnotationsAsync( }; } +export function searchEmptyFrameAsync( + sessionInstance: any, + frameFrom: number, + frameTo: number, +): ThunkAction { + return async (dispatch: ActionCreator): Promise => { + try { + const frame = await sessionInstance.annotations.searchEmpty(frameFrom, frameTo); + if (frame !== null) { + dispatch(changeFrameAsync(frame)); + } + } catch (error) { + dispatch({ + type: AnnotationActionTypes.SEARCH_EMPTY_FRAME_FAILED, + payload: { + error, + }, + }); + } + }; +} + export function pasteShapeAsync(): ThunkAction { return async (dispatch: ActionCreator): Promise => { const { diff --git a/cvat-ui/src/assets/next-empty-icon.svg b/cvat-ui/src/assets/next-empty-icon.svg new file mode 100644 index 00000000..4beef76a --- /dev/null +++ b/cvat-ui/src/assets/next-empty-icon.svg @@ -0,0 +1,3 @@ + diff --git a/cvat-ui/src/assets/next-filtered-icon.svg b/cvat-ui/src/assets/next-filtered-icon.svg new file mode 100644 index 00000000..2fcbaf5b --- /dev/null +++ b/cvat-ui/src/assets/next-filtered-icon.svg @@ -0,0 +1,3 @@ + diff --git a/cvat-ui/src/assets/previous-empty-icon.svg b/cvat-ui/src/assets/previous-empty-icon.svg new file mode 100644 index 00000000..72eb7298 --- /dev/null +++ b/cvat-ui/src/assets/previous-empty-icon.svg @@ -0,0 +1,3 @@ + diff --git a/cvat-ui/src/assets/previous-filtered-icon.svg b/cvat-ui/src/assets/previous-filtered-icon.svg new file mode 100644 index 00000000..2c131896 --- /dev/null +++ b/cvat-ui/src/assets/previous-filtered-icon.svg @@ -0,0 +1,3 @@ + diff --git a/cvat-ui/src/components/annotation-page/styles.scss b/cvat-ui/src/components/annotation-page/styles.scss index 4f7aaa8a..6836cfac 100644 --- a/cvat-ui/src/components/annotation-page/styles.scss +++ b/cvat-ui/src/components/annotation-page/styles.scss @@ -300,3 +300,20 @@ } } } + +.cvat-player-previous-inlined-button, +.cvat-player-next-inlined-button, +.cvat-player-previous-filtered-inlined-button, +.cvat-player-next-filtered-inlined-button, +.cvat-player-previous-empty-inlined-button, +.cvat-player-next-empty-inlined-button { + color: $player-buttons-color; + + &:not(:first-child) { + margin-left: 12px; + } + + > svg { + transform: scale(1.8); + } +} diff --git a/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx b/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx index 1737c427..8902860e 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx @@ -7,14 +7,19 @@ import React from 'react'; import { Col } from 'antd/lib/grid'; import Icon from 'antd/lib/icon'; import Tooltip from 'antd/lib/tooltip'; +import Popover from 'antd/lib/popover'; import { FirstIcon, BackJumpIcon, PreviousIcon, + PreviousFilteredIcon, + PreviousEmptyIcon, PlayIcon, PauseIcon, NextIcon, + NextFilteredIcon, + NextEmptyIcon, ForwardJumpIcon, LastIcon, } from 'icons'; @@ -26,6 +31,8 @@ interface Props { previousFrameShortcut: string; forwardShortcut: string; backwardShortcut: string; + prevButtonType: string; + nextButtonType: string; onSwitchPlay(): void; onPrevFrame(): void; onNextFrame(): void; @@ -33,6 +40,8 @@ interface Props { onBackward(): void; onFirstFrame(): void; onLastFrame(): void; + setPrevButton(type: 'regular' | 'filtered' | 'empty'): void; + setNextButton(type: 'regular' | 'filtered' | 'empty'): void; } function PlayerButtons(props: Props): JSX.Element { @@ -43,6 +52,8 @@ function PlayerButtons(props: Props): JSX.Element { previousFrameShortcut, forwardShortcut, backwardShortcut, + prevButtonType, + nextButtonType, onSwitchPlay, onPrevFrame, onNextFrame, @@ -50,8 +61,37 @@ function PlayerButtons(props: Props): JSX.Element { onBackward, onFirstFrame, onLastFrame, + setPrevButton, + setNextButton, } = props; + const prevRegularText = 'Go back'; + const prevFilteredText = 'Go back with a filter'; + const prevEmptyText = 'Go back to an empty frame'; + const nextRegularText = 'Go next'; + const nextFilteredText = 'Go next with a filter'; + const nextEmptyText = 'Go next to an empty frame'; + + let prevButton = ; + let prevButtonTooltipMessage = prevRegularText; + if (prevButtonType === 'filtered') { + prevButton = ; + prevButtonTooltipMessage = prevFilteredText; + } else if (prevButtonType === 'empty') { + prevButton = ; + prevButtonTooltipMessage = prevEmptyText; + } + + let nextButton = ; + let nextButtonTooltipMessage = nextRegularText; + if (nextButtonType === 'filtered') { + nextButton = ; + nextButtonTooltipMessage = nextFilteredText; + } else if (nextButtonType === 'empty') { + nextButton = ; + nextButtonTooltipMessage = nextEmptyText; + } + return ( @@ -60,9 +100,45 @@ function PlayerButtons(props: Props): JSX.Element { - - - + + + { + setPrevButton('regular'); + }} + /> + + + { + setPrevButton('filtered'); + }} + /> + + + { + setPrevButton('empty'); + }} + /> + + + )} + > + + {prevButton} + + {!playing ? ( @@ -84,9 +160,45 @@ function PlayerButtons(props: Props): JSX.Element { )} - - - + + + { + setNextButton('regular'); + }} + /> + + + { + setNextButton('filtered'); + }} + /> + + + { + setNextButton('empty'); + }} + /> + + + )} + > + + {nextButton} + + 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 59464148..c3e14c31 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 @@ -34,6 +34,8 @@ interface Props { previousFrameShortcut: string; forwardShortcut: string; backwardShortcut: string; + prevButtonType: string; + nextButtonType: string; focusFrameInputShortcut: string; changeWorkspace(workspace: Workspace): void; showStatistics(): void; @@ -45,6 +47,8 @@ interface Props { onBackward(): void; onFirstFrame(): void; onLastFrame(): void; + setPrevButtonType(type: 'regular' | 'filtered' | 'empty'): void; + setNextButtonType(type: 'regular' | 'filtered' | 'empty'): void; onSliderChange(value: SliderValue): void; onInputChange(value: number): void; onURLIconClick(): void; @@ -73,6 +77,8 @@ export default function AnnotationTopBarComponent(props: Props): JSX.Element { previousFrameShortcut, forwardShortcut, backwardShortcut, + prevButtonType, + nextButtonType, focusFrameInputShortcut, showStatistics, changeWorkspace, @@ -84,6 +90,8 @@ export default function AnnotationTopBarComponent(props: Props): JSX.Element { onBackward, onFirstFrame, onLastFrame, + setPrevButtonType, + setNextButtonType, onSliderChange, onInputChange, onURLIconClick, @@ -114,6 +122,8 @@ export default function AnnotationTopBarComponent(props: Props): JSX.Element { previousFrameShortcut={previousFrameShortcut} forwardShortcut={forwardShortcut} backwardShortcut={backwardShortcut} + prevButtonType={prevButtonType} + nextButtonType={nextButtonType} onPrevFrame={onPrevFrame} onNextFrame={onNextFrame} onForward={onForward} @@ -121,6 +131,8 @@ export default function AnnotationTopBarComponent(props: Props): JSX.Element { onFirstFrame={onFirstFrame} onLastFrame={onLastFrame} onSwitchPlay={onSwitchPlay} + setPrevButton={setPrevButtonType} + setNextButton={setNextButtonType} /> { +class AnnotationTopBarContainer extends React.PureComponent { private inputFrameRef: React.RefObject; private autoSaveInterval: number | undefined; private unblock: any; @@ -165,15 +175,14 @@ class AnnotationTopBarContainer extends React.PureComponent { constructor(props: Props) { super(props); this.inputFrameRef = React.createRef(); + this.state = { + prevButtonType: 'regular', + nextButtonType: 'regular', + }; } public componentDidMount(): void { - const { - autoSaveInterval, - history, - jobInstance, - } = this.props; - + const { autoSaveInterval, history, jobInstance } = this.props; this.autoSaveInterval = window.setInterval(this.autoSave.bind(this), autoSaveInterval); this.unblock = history.block((location: any) => { @@ -273,10 +282,7 @@ class AnnotationTopBarContainer extends React.PureComponent { }; private showStatistics = (): void => { - const { - jobInstance, - showStatistics, - } = this.props; + const { jobInstance, showStatistics } = this.props; showStatistics(jobInstance); }; @@ -333,12 +339,16 @@ class AnnotationTopBarContainer extends React.PureComponent { }; private onPrevFrame = (): void => { + const { prevButtonType } = this.state; const { frameNumber, jobInstance, playing, onSwitchPlay, + searchAnnotations, + searchEmptyFrame, } = this.props; + const { startFrame } = jobInstance; const newFrame = Math .max(jobInstance.startFrame, frameNumber - 1); @@ -346,17 +356,27 @@ class AnnotationTopBarContainer extends React.PureComponent { if (playing) { onSwitchPlay(false); } - this.changeFrame(newFrame); + if (prevButtonType === 'regular') { + this.changeFrame(newFrame); + } else if (prevButtonType === 'filtered') { + searchAnnotations(jobInstance, frameNumber - 1, startFrame); + } else { + searchEmptyFrame(jobInstance, frameNumber - 1, startFrame); + } } }; private onNextFrame = (): void => { + const { nextButtonType } = this.state; const { frameNumber, jobInstance, playing, onSwitchPlay, + searchAnnotations, + searchEmptyFrame, } = this.props; + const { stopFrame } = jobInstance; const newFrame = Math .min(jobInstance.stopFrame, frameNumber + 1); @@ -364,7 +384,13 @@ class AnnotationTopBarContainer extends React.PureComponent { if (playing) { onSwitchPlay(false); } - this.changeFrame(newFrame); + if (nextButtonType === 'regular') { + this.changeFrame(newFrame); + } else if (nextButtonType === 'filtered') { + searchAnnotations(jobInstance, frameNumber + 1, stopFrame); + } else { + searchEmptyFrame(jobInstance, frameNumber + 1, stopFrame); + } } }; @@ -404,12 +430,20 @@ class AnnotationTopBarContainer extends React.PureComponent { } }; - private onSaveAnnotation = (): void => { - const { - onSaveAnnotation, - jobInstance, - } = this.props; + private onSetPreviousButtonType = (type: 'regular' | 'filtered' | 'empty'): void => { + this.setState({ + prevButtonType: type, + }); + }; + + private onSetNextButtonType = (type: 'regular' | 'filtered' | 'empty'): void => { + this.setState({ + nextButtonType: type, + }); + }; + private onSaveAnnotation = (): void => { + const { onSaveAnnotation, jobInstance } = this.props; onSaveAnnotation(jobInstance); }; @@ -422,12 +456,7 @@ class AnnotationTopBarContainer extends React.PureComponent { }; private onChangePlayerInputValue = (value: number): void => { - const { - onSwitchPlay, - playing, - frameNumber, - } = this.props; - + const { onSwitchPlay, playing, frameNumber } = this.props; if (value !== frameNumber) { if (playing) { onSwitchPlay(false); @@ -438,10 +467,7 @@ class AnnotationTopBarContainer extends React.PureComponent { private onURLIconClick = (): void => { const { frameNumber } = this.props; - const { - origin, - pathname, - } = window.location; + const { origin, pathname } = window.location; const url = `${origin}${pathname}?frame=${frameNumber}`; copy(url); }; @@ -474,6 +500,7 @@ class AnnotationTopBarContainer extends React.PureComponent { public render(): JSX.Element { + const { nextButtonType, prevButtonType } = this.state; const { playing, saving, @@ -600,6 +627,8 @@ class AnnotationTopBarContainer extends React.PureComponent { onBackward={this.onBackward} onFirstFrame={this.onFirstFrame} onLastFrame={this.onLastFrame} + setNextButtonType={this.onSetNextButtonType} + setPrevButtonType={this.onSetPreviousButtonType} onSliderChange={this.onChangePlayerSliderValue} onInputChange={this.onChangePlayerInputValue} onURLIconClick={this.onURLIconClick} @@ -623,6 +652,8 @@ class AnnotationTopBarContainer extends React.PureComponent { previousFrameShortcut={normalizedKeyMap.PREV_FRAME} forwardShortcut={normalizedKeyMap.FORWARD_FRAME} backwardShortcut={normalizedKeyMap.BACKWARD_FRAME} + nextButtonType={nextButtonType} + prevButtonType={prevButtonType} focusFrameInputShortcut={normalizedKeyMap.FOCUS_INPUT_FRAME} onUndoClick={this.undo} onRedoClick={this.redo} diff --git a/cvat-ui/src/icons.tsx b/cvat-ui/src/icons.tsx index a3e92e92..9a071b1c 100644 --- a/cvat-ui/src/icons.tsx +++ b/cvat-ui/src/icons.tsx @@ -28,9 +28,13 @@ import SVGRedoIcon from './assets/redo-icon.svg'; import SVGFirstIcon from './assets/first-icon.svg'; import SVGBackJumpIcon from './assets/back-jump-icon.svg'; import SVGPreviousIcon from './assets/previous-icon.svg'; +import SVGPreviousFilteredIcon from './assets/previous-filtered-icon.svg'; +import SVGPreviousEmptyIcon from './assets/previous-empty-icon.svg'; import SVGPlayIcon from './assets/play-icon.svg'; import SVGPauseIcon from './assets/pause-icon.svg'; import SVGNextIcon from './assets/next-icon.svg'; +import SVGNextFilteredIcon from './assets/next-filtered-icon.svg'; +import SVGNextEmptyIcon from './assets/next-empty-icon.svg'; import SVGForwardJumpIcon from './assets/forward-jump-icon.svg'; import SVGLastIcon from './assets/last-icon.svg'; import SVGInfoIcon from './assets/info-icon.svg'; @@ -117,6 +121,12 @@ export const BackJumpIcon = React.memo( export const PreviousIcon = React.memo( (): JSX.Element => , ); +export const PreviousFilteredIcon = React.memo( + (): JSX.Element => , +); +export const PreviousEmptyIcon = React.memo( + (): JSX.Element => , +); export const PauseIcon = React.memo( (): JSX.Element => , ); @@ -126,6 +136,12 @@ export const PlayIcon = React.memo( export const NextIcon = React.memo( (): JSX.Element => , ); +export const NextFilteredIcon = React.memo( + (): JSX.Element => , +); +export const NextEmptyIcon = React.memo( + (): JSX.Element => , +); export const ForwardJumpIcon = React.memo( (): JSX.Element => , ); diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index b74f67cc..d0565886 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -245,6 +245,7 @@ export interface NotificationsState { undo: null | ErrorState; redo: null | ErrorState; search: null | ErrorState; + searchEmptyFrame: null | ErrorState; savingLogs: null | ErrorState; }; boundaries: { diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index 96863e6b..835d24fa 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -79,6 +79,7 @@ const defaultState: NotificationsState = { undo: null, redo: null, search: null, + searchEmptyFrame: null, savingLogs: null, }, boundaries: { @@ -855,6 +856,21 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case AnnotationActionTypes.SEARCH_EMPTY_FRAME_FAILED: { + return { + ...state, + errors: { + ...state.errors, + annotation: { + ...state.errors.annotation, + searchEmptyFrame: { + message: 'Could not search an empty frame', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } case AnnotationActionTypes.SAVE_LOGS_FAILED: { return { ...state,