diff --git a/changelog.d/20250203_125000_klakhov_refactor_settings.md b/changelog.d/20250203_125000_klakhov_refactor_settings.md new file mode 100644 index 000000000000..4a9ff43d87f1 --- /dev/null +++ b/changelog.d/20250203_125000_klakhov_refactor_settings.md @@ -0,0 +1,8 @@ +### Changed + +- Client settings are now saved automatically + () + +### Added +- Gamma filter settings are now automatically saved and restored upon reload + () diff --git a/cvat-ui/src/actions/settings-actions.ts b/cvat-ui/src/actions/settings-actions.ts index e93f356787d0..edb9a095fa74 100644 --- a/cvat-ui/src/actions/settings-actions.ts +++ b/cvat-ui/src/actions/settings-actions.ts @@ -3,11 +3,17 @@ // // SPDX-License-Identifier: MIT +import _ from 'lodash'; import { AnyAction } from 'redux'; +import { ThunkAction } from 'utils/redux'; import { GridColor, ColorBy, SettingsState, ToolsBlockerState, + CombinedState, } from 'reducers'; -import { ImageFilter, ImageFilterAlias } from 'utils/image-processing'; +import { ImageFilter, ImageFilterAlias, SerializedImageFilter } from 'utils/image-processing'; +import { conflict, conflictDetector } from 'utils/conflict-detector'; +import GammaCorrection, { GammaFilterOptions } from 'utils/fabric-wrapper/gamma-correciton'; +import { shortcutsActions } from './shortcuts-actions'; export enum SettingsActionTypes { SWITCH_ROTATE_ALL = 'SWITCH_ROTATE_ALL', @@ -409,3 +415,98 @@ export function resetImageFilters(): AnyAction { payload: {}, }; } + +export function restoreSettingsAsync(): ThunkAction { + return async (dispatch, getState): Promise => { + const state: CombinedState = getState(); + const { settings, shortcuts } = state; + + dispatch(shortcutsActions.setDefaultShortcuts(structuredClone(shortcuts.keyMap))); + + const settingsString = localStorage.getItem('clientSettings') as string; + if (!settingsString) return; + + const loadedSettings = JSON.parse(settingsString); + const newSettings = { + player: settings.player, + workspace: settings.workspace, + imageFilters: [], + } as Pick; + + Object.entries(_.pick(newSettings, ['player', 'workspace'])).forEach(([sectionKey, section]) => { + for (const key of Object.keys(section)) { + const settedValue = loadedSettings[sectionKey]?.[key]; + if (settedValue !== undefined) { + Object.defineProperty(newSettings[sectionKey as 'player' | 'workspace'], key, { value: settedValue }); + } + } + }); + + if ('imageFilters' in loadedSettings) { + loadedSettings.imageFilters.forEach((filter: SerializedImageFilter) => { + if (filter.alias === ImageFilterAlias.GAMMA_CORRECTION) { + const modifier = new GammaCorrection(filter.params as GammaFilterOptions); + newSettings.imageFilters.push({ + modifier, + alias: ImageFilterAlias.GAMMA_CORRECTION, + }); + } + }); + } + + dispatch(setSettings(newSettings)); + + if ('shortcuts' in loadedSettings) { + const updateKeyMap = structuredClone(shortcuts.keyMap); + for (const [key, value] of Object.entries(loadedSettings.shortcuts.keyMap)) { + if (key in updateKeyMap) { + updateKeyMap[key].sequences = (value as { sequences: string[] }).sequences; + } + } + + for (const key of Object.keys(updateKeyMap)) { + const currValue = { + [key]: { ...updateKeyMap[key] }, + }; + const conflictingShortcuts = conflictDetector(currValue, shortcuts.keyMap); + if (conflictingShortcuts) { + for (const conflictingShortcut of Object.keys(conflictingShortcuts)) { + for (const sequence of currValue[key].sequences) { + for (const conflictingSequence of conflictingShortcuts[conflictingShortcut].sequences) { + if (conflict(sequence, conflictingSequence)) { + updateKeyMap[conflictingShortcut].sequences = [ + ...updateKeyMap[conflictingShortcut].sequences.filter( + (s: string) => s !== conflictingSequence, + ), + ]; + } + } + } + } + } + } + dispatch(shortcutsActions.registerShortcuts(updateKeyMap)); + } + }; +} + +export function updateCachedSettings(settings: CombinedState['settings'], shortcuts: CombinedState['shortcuts']): void { + const supportedImageFilters = [ImageFilterAlias.GAMMA_CORRECTION]; + const settingsForSaving = { + player: settings.player, + workspace: settings.workspace, + shortcuts: { + keyMap: Object.entries(shortcuts.keyMap).reduce>( + (acc, [key, value]) => { + if (key in shortcuts.defaultState) { + acc[key] = { sequences: value.sequences }; + } + return acc; + }, {}), + }, + imageFilters: settings.imageFilters.filter((imageFilter) => supportedImageFilters.includes(imageFilter.alias)) + .map((imageFilter) => imageFilter.modifier.toJSON()), + }; + + localStorage.setItem('clientSettings', JSON.stringify(settingsForSaving)); +} diff --git a/cvat-ui/src/components/header/settings-modal/settings-modal.tsx b/cvat-ui/src/components/header/settings-modal/settings-modal.tsx index 4a870f970a1f..fffe70d50424 100644 --- a/cvat-ui/src/components/header/settings-modal/settings-modal.tsx +++ b/cvat-ui/src/components/header/settings-modal/settings-modal.tsx @@ -4,24 +4,20 @@ // SPDX-License-Identifier: MIT import './styles.scss'; -import _ from 'lodash'; -import React, { useCallback, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import Tabs from 'antd/lib/tabs'; import Text from 'antd/lib/typography/Text'; import Modal from 'antd/lib/modal/Modal'; import Button from 'antd/lib/button'; import notification from 'antd/lib/notification'; -import Tooltip from 'antd/lib/tooltip'; import { PlayCircleOutlined, LaptopOutlined, BuildOutlined } from '@ant-design/icons'; -import { setSettings } from 'actions/settings-actions'; -import { shortcutsActions } from 'actions/shortcuts-actions'; +import { restoreSettingsAsync, updateCachedSettings } from 'actions/settings-actions'; import WorkspaceSettingsContainer from 'containers/header/settings-modal/workspace-settings'; import PlayerSettingsContainer from 'containers/header/settings-modal/player-settings'; import ShortcutsSettingsContainer from 'containers/header/settings-modal/shortcuts-settings'; import { CombinedState } from 'reducers'; -import { conflict, conflictDetector } from 'utils/conflict-detector'; interface SettingsModalProps { visible: boolean; @@ -33,92 +29,49 @@ function SettingsModal(props: SettingsModalProps): JSX.Element { const settings = useSelector((state: CombinedState) => state.settings); const shortcuts = useSelector((state: CombinedState) => state.shortcuts); + const [settingsInitialized, setSettingsInitialized] = useState(false); const dispatch = useDispatch(); - const onSaveSettings = useCallback(() => { - const settingsForSaving: any = { - shortcuts: { - keyMap: {}, - }, - }; - for (const [key, value] of Object.entries(settings)) { - if (['player', 'workspace'].includes(key)) { - settingsForSaving[key] = value; - } - } - for (const [key] of Object.entries(shortcuts.keyMap)) { - if (key in shortcuts.defaultState) { - settingsForSaving.shortcuts.keyMap[key] = { - sequences: shortcuts.keyMap[key].sequences, - }; - } - } - - localStorage.setItem('clientSettings', JSON.stringify(settingsForSaving)); - notification.success({ - message: 'Settings were successfully saved', - className: 'cvat-notification-notice-save-settings-success', - }); + useEffect(() => { + if (!settingsInitialized) return; - onClose(); - }, [onClose, settings, shortcuts]); + updateCachedSettings(settings, shortcuts); + }, [settingsInitialized, settings, shortcuts]); useEffect(() => { try { - dispatch(shortcutsActions.setDefaultShortcuts(structuredClone(shortcuts.keyMap))); - const newSettings = _.pick(settings, 'player', 'workspace'); - const settingsString = localStorage.getItem('clientSettings') as string; - if (!settingsString) return; - const loadedSettings = JSON.parse(settingsString); - for (const [sectionKey, section] of Object.entries(newSettings)) { - for (const [key, value] of Object.entries(section)) { - let settedValue = value; - if (sectionKey in loadedSettings && key in loadedSettings[sectionKey]) { - settedValue = loadedSettings[sectionKey][key]; - Object.defineProperty(newSettings[(sectionKey as 'player' | 'workspace')], key, { value: settedValue }); - } - } - } - dispatch(setSettings(newSettings)); - if ('shortcuts' in loadedSettings) { - const updateKeyMap = structuredClone(shortcuts.keyMap); - for (const key of Object.keys(loadedSettings.shortcuts.keyMap)) { - const value = loadedSettings.shortcuts.keyMap[key]; - if (key in updateKeyMap) { - updateKeyMap[key].sequences = value.sequences; - } - } - for (const key of Object.keys(updateKeyMap)) { - const currValue = { - [key]: { ...updateKeyMap[key] }, - }; - const conflictingShortcuts = conflictDetector(currValue, shortcuts.keyMap); - if (conflictingShortcuts) { - for (const conflictingShortcut of Object.keys(conflictingShortcuts)) { - for (const sequence of currValue[key].sequences) { - for (const conflictingSequence of conflictingShortcuts[conflictingShortcut].sequences) { - if (conflict(sequence, conflictingSequence)) { - updateKeyMap[conflictingShortcut].sequences = [ - ...updateKeyMap[conflictingShortcut].sequences.filter( - (s: string) => s !== conflictingSequence, - ), - ]; - } - } - } - } - } - } - dispatch(shortcutsActions.registerShortcuts(updateKeyMap)); - } + dispatch(restoreSettingsAsync()); } catch { notification.error({ message: 'Failed to load settings from local storage', className: 'cvat-notification-notice-load-settings-fail', }); + } finally { + setSettingsInitialized(true); } }, []); + const tabItems = [ + { + key: 'player', + label: Player, + icon: , + children: , + }, + { + key: 'workspace', + label: Workspace, + icon: , + children: , + }, + { + key: 'shortcuts', + label: Shortcuts, + icon: , + children: , + }, + ]; + return ( - - - - - + )} >
- - - Player - - ), - children: , - }, { - key: 'workspace', - label: ( - - - Workspace - - ), - children: , - }, { - key: 'shortcuts', - label: ( - - - Shortcuts - - ), - children: , - }]} - /> +
); diff --git a/cvat-ui/src/utils/conflict-detector.ts b/cvat-ui/src/utils/conflict-detector.ts index ebd8b3c6e8ac..a7c83479c1e2 100644 --- a/cvat-ui/src/utils/conflict-detector.ts +++ b/cvat-ui/src/utils/conflict-detector.ts @@ -103,7 +103,8 @@ function updatedFlatKeyMap(scope: string, flatKeyMap: FlatKeyMap): FlatKeyMapIte export function conflictDetector( shortcuts: Record, - keyMap: KeyMap): Record | null { + keyMap: KeyMap, +): Record | null { const flatKeyMap: FlatKeyMap = initializeFlatKeyMap(keyMap); const conflictingItems: Record = {}; diff --git a/cvat-ui/src/utils/fabric-wrapper/gamma-correciton.ts b/cvat-ui/src/utils/fabric-wrapper/gamma-correciton.ts index 1d6d138df95b..76dc55551a8a 100644 --- a/cvat-ui/src/utils/fabric-wrapper/gamma-correciton.ts +++ b/cvat-ui/src/utils/fabric-wrapper/gamma-correciton.ts @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT import { fabric } from 'fabric'; +import { ImageFilterAlias, SerializedImageFilter } from 'utils/image-processing'; import FabricFilter from './fabric-wrapper'; export interface GammaFilterOptions { @@ -34,6 +35,15 @@ export default class GammaCorrection extends FabricFilter { this.#gamma = newGamma; } + public toJSON(): SerializedImageFilter { + return { + alias: ImageFilterAlias.GAMMA_CORRECTION, + params: { + gamma: this.#gamma, + }, + }; + } + get gamma(): number { return this.#gamma[0]; } diff --git a/cvat-ui/src/utils/image-processing.tsx b/cvat-ui/src/utils/image-processing.tsx index 6e652ccba13d..51165069e4c8 100644 --- a/cvat-ui/src/utils/image-processing.tsx +++ b/cvat-ui/src/utils/image-processing.tsx @@ -5,12 +5,17 @@ import { fabric } from 'fabric'; export type ConfigurableFilterType = fabric.IBaseFilter; +export interface SerializedImageFilter { + alias: ImageFilterAlias; + params: object; +} export interface ImageProcessing { filter: ConfigurableFilterType | null; currentProcessedImage: number | null; processImage: (src: ImageData, frameNumber: number) => ImageData; configure: (options: object) => void; + toJSON: () => SerializedImageFilter; } /* eslint @typescript-eslint/no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ @@ -23,6 +28,10 @@ export class BaseImageFilter implements ImageProcessing { } configure(_options: object): void {} + + toJSON(): SerializedImageFilter { + throw new Error('Method is not implemented'); + } } export interface ImageFilter { diff --git a/tests/cypress/e2e/actions_tasks/case_100_settings_default_number_of_points_in_polygon_approximation.js b/tests/cypress/e2e/actions_tasks/case_100_settings_default_number_of_points_in_polygon_approximation.js index d2744d1fcbd6..2e9ece7fae4c 100644 --- a/tests/cypress/e2e/actions_tasks/case_100_settings_default_number_of_points_in_polygon_approximation.js +++ b/tests/cypress/e2e/actions_tasks/case_100_settings_default_number_of_points_in_polygon_approximation.js @@ -44,8 +44,7 @@ context('Settings. Default number of points in polygon approximation.', () => { const sliderAttrValueNow = slider.attr('aria-valuenow'); const sliderAttrValuemin = slider.attr('aria-valuemin'); const sliderAttrValuemax = slider.attr('aria-valuemax'); - cy.saveSettings(); - cy.closeNotification('.cvat-notification-notice-save-settings-success'); + cy.closeSettings(); cy.reload(); testCheckSliderAttrValuenow(sliderAttrValueNow); cy.contains('strong', 'less').click(); diff --git a/tests/cypress/e2e/actions_tasks/case_68_saving_settings_local_storage.js b/tests/cypress/e2e/actions_tasks/case_68_saving_settings_local_storage.js index eb04680e25ce..35183f7e6aca 100644 --- a/tests/cypress/e2e/actions_tasks/case_68_saving_settings_local_storage.js +++ b/tests/cypress/e2e/actions_tasks/case_68_saving_settings_local_storage.js @@ -1,4 +1,5 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -18,11 +19,11 @@ context('Saving setting to local storage.', () => { cy.get('.cvat-workspace-settings-show-interpolated').find('[type="checkbox"]')[method](); cy.get('.cvat-workspace-settings-show-text-always').find('[type="checkbox"]')[method](); cy.get('.cvat-workspace-settings-autoborders').find('[type="checkbox"]')[method](); - cy.saveSettings(); - cy.get('.cvat-notification-notice-save-settings-success') - .should('exist') - .find('[data-icon="close"]') - .click(); + cy.closeSettings(); + cy.window().then((window) => { + const { localStorage } = window; + cy.wrap(localStorage.getItem('clientSettings')).should('exist').and('not.be.null'); + }); } function testCheckedSettings(checked = false) { diff --git a/tests/cypress/e2e/features/shortcuts.js b/tests/cypress/e2e/features/shortcuts.js index be27d828f8b8..2793c5788579 100644 --- a/tests/cypress/e2e/features/shortcuts.js +++ b/tests/cypress/e2e/features/shortcuts.js @@ -150,13 +150,6 @@ context('Customizable Shortcuts', () => { cy.openSettings(); registerF2(false); registerF2(true); - }); - }); - - describe('Saving, Clearing and Restoring to Default', () => { - it('Saving shortcuts and checking if they persist', () => { - cy.openSettings(); - cy.saveSettings(); cy.reload(); cy.openSettings(); cy.contains('Shortcuts').click(); @@ -166,6 +159,9 @@ context('Customizable Shortcuts', () => { cy.get('.ant-select-selection-overflow-item').contains('ctrl+space').should('exist'); }); }); + }); + + describe('Saving, Clearing and Restoring to Default', () => { it('Cleaning Shortcuts', () => { cy.get('.cvat-shortcuts-settings-collapse-item .cvat-shortcuts-settings-select').first().click(); cy.get('.cvat-shortcuts-settings-collapse-item .cvat-shortcuts-settings-select .ant-select-selection-item-remove').first().click(); @@ -177,14 +173,6 @@ context('Customizable Shortcuts', () => { cy.get('.ant-select-selection-overflow-item').should('not.have.text'); }); }); - it('Restoring Defaults', () => { - cy.get('.cvat-shortcuts-settings-restore').click(); - cy.get('.cvat-shortcuts-settings-restore-modal .ant-btn-primary').click(); - cy.get( - '.cvat-shortcuts-settings-collapse-item .cvat-shortcuts-settings-select .ant-select-selection-overflow-item').first().should('exist').and('be.visible'); - cy.get('.cvat-shortcuts-settings-collapse-item .cvat-shortcuts-settings-select .ant-select-selection-overflow-item').first().contains('f1'); - cy.saveSettings(); - }); it('Modifying a shortcut via local storage and testing if its conflict is resolved', () => { cy.window().then((window) => { const { localStorage } = window; @@ -202,6 +190,18 @@ context('Customizable Shortcuts', () => { cy.get('.cvat-shortcuts-settings-restore').click(); cy.get('.cvat-shortcuts-settings-restore-modal .ant-btn-primary').click(); }); + it('Restoring Defaults', () => { + cy.get('.cvat-shortcuts-settings-restore').click(); + cy.get('.cvat-shortcuts-settings-restore-modal .ant-btn-primary').click(); + cy.get( + '.cvat-shortcuts-settings-collapse-item .cvat-shortcuts-settings-select .ant-select-selection-overflow-item').first().should('exist').and('be.visible'); + cy.get('.cvat-shortcuts-settings-collapse-item .cvat-shortcuts-settings-select .ant-select-selection-overflow-item').first().contains('f1'); + cy.closeSettings(); + cy.window().then((window) => { + const { localStorage } = window; + cy.wrap(localStorage.getItem('clientSettings')).should('exist').and('not.be.null'); + }); + }); }); describe('Tag Annotation, Attribute Annotation and Labels', () => { diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index 281c2fe41634..fa6f7937c23e 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -728,12 +728,6 @@ Cypress.Commands.add('closeSettings', () => { cy.get('.cvat-settings-modal').should('not.be.visible'); }); -Cypress.Commands.add('saveSettings', () => { - cy.get('.cvat-settings-modal').within(() => { - cy.contains('button', 'Save').click(); - }); -}); - Cypress.Commands.add('changeWorkspace', (mode) => { cy.get('.cvat-workspace-selector').click(); cy.get('.cvat-workspace-selector-dropdown').within(() => {