From 4a50f8fd882b12e07db0c39de4ad877c071a2569 Mon Sep 17 00:00:00 2001 From: Tom Herold Date: Fri, 4 Jul 2025 15:21:28 +0200 Subject: [PATCH 1/2] Refactor action bar components to functional components using hooks This commit converts several class components in the action bar to functional components, enhancing code readability and maintainability. The following components were refactored: - DatasetPositionView - SaveButton - TracingActionsView - ViewModesView --- .../view/action-bar/dataset_position_view.tsx | 174 ++++---- .../viewer/view/action-bar/save_button.tsx | 225 +++++----- .../view/action-bar/tracing_actions_view.tsx | 396 ++++++++---------- .../view/action-bar/view_modes_view.tsx | 112 +++-- 4 files changed, 400 insertions(+), 507 deletions(-) diff --git a/frontend/javascripts/viewer/view/action-bar/dataset_position_view.tsx b/frontend/javascripts/viewer/view/action-bar/dataset_position_view.tsx index 2d53a77f6c2..2a02015176f 100644 --- a/frontend/javascripts/viewer/view/action-bar/dataset_position_view.tsx +++ b/frontend/javascripts/viewer/view/action-bar/dataset_position_view.tsx @@ -2,29 +2,20 @@ import { PushpinOutlined, ReloadOutlined } from "@ant-design/icons"; import { Space } from "antd"; import FastTooltip from "components/fast_tooltip"; import { V3 } from "libs/mjs"; +import { useWkSelector } from "libs/react_hooks"; import Toast from "libs/toast"; import { Vector3Input } from "libs/vector_input"; import message from "messages"; import type React from "react"; -import { PureComponent } from "react"; -import { connect } from "react-redux"; -import type { APIDataset } from "types/api_types"; -import type { Vector3, ViewMode } from "viewer/constants"; +import type { Vector3 } from "viewer/constants"; import constants from "viewer/constants"; import { getDatasetExtentInVoxel } from "viewer/model/accessors/dataset_accessor"; import { getPosition, getRotation } from "viewer/model/accessors/flycam_accessor"; import { setPositionAction, setRotationAction } from "viewer/model/actions/flycam_actions"; -import type { Flycam, Task, WebknossosState } from "viewer/store"; import Store from "viewer/store"; import { ShareButton } from "viewer/view/action-bar/share_modal_view"; import ButtonComponent from "viewer/view/components/button_component"; -type Props = { - flycam: Flycam; - viewMode: ViewMode; - dataset: APIDataset; - task: Task | null | undefined; -}; const positionIconStyle: React.CSSProperties = { transform: "rotate(-45deg)", marginRight: 0, @@ -42,29 +33,33 @@ const positionInputErrorStyle: React.CSSProperties = { ...warningColors, }; -class DatasetPositionView extends PureComponent { - copyPositionToClipboard = async () => { - const position = V3.floor(getPosition(this.props.flycam)).join(", "); +function DatasetPositionView() { + const flycam = useWkSelector((state) => state.flycam); + const viewMode = useWkSelector((state) => state.temporaryConfiguration.viewMode); + const dataset = useWkSelector((state) => state.dataset); + const task = useWkSelector((state) => state.task); + + const copyPositionToClipboard = async () => { + const position = V3.floor(getPosition(flycam)).join(", "); await navigator.clipboard.writeText(position); Toast.success("Position copied to clipboard"); }; - copyRotationToClipboard = async () => { - const rotation = V3.round(getRotation(this.props.flycam)).join(", "); + const copyRotationToClipboard = async () => { + const rotation = V3.round(getRotation(flycam)).join(", "); await navigator.clipboard.writeText(rotation); Toast.success("Rotation copied to clipboard"); }; - handleChangePosition = (position: Vector3) => { + const handleChangePosition = (position: Vector3) => { Store.dispatch(setPositionAction(position)); }; - handleChangeRotation = (rotation: Vector3) => { + const handleChangeRotation = (rotation: Vector3) => { Store.dispatch(setRotationAction(rotation)); }; - isPositionOutOfBounds = (position: Vector3) => { - const { dataset, task } = this.props; + const isPositionOutOfBounds = (position: Vector3) => { const { min: datasetMin, max: datasetMax } = getDatasetExtentInVoxel(dataset); const isPositionOutOfBounds = (min: Vector3, max: Vector3) => @@ -95,100 +90,87 @@ class DatasetPositionView extends PureComponent { }; }; - render() { - const position = V3.floor(getPosition(this.props.flycam)); - const { isOutOfDatasetBounds, isOutOfTaskBounds } = this.isPositionOutOfBounds(position); - const iconColoringStyle = isOutOfDatasetBounds || isOutOfTaskBounds ? iconErrorStyle : {}; - const positionInputStyle = - isOutOfDatasetBounds || isOutOfTaskBounds - ? positionInputErrorStyle - : positionInputDefaultStyle; - let maybeErrorMessage = null; - - if (isOutOfDatasetBounds) { - maybeErrorMessage = message["tracing.out_of_dataset_bounds"]; - } else if (!maybeErrorMessage && isOutOfTaskBounds) { - maybeErrorMessage = message["tracing.out_of_task_bounds"]; - } + const position = V3.floor(getPosition(flycam)); + const { isOutOfDatasetBounds, isOutOfTaskBounds } = isPositionOutOfBounds(position); + const iconColoringStyle = isOutOfDatasetBounds || isOutOfTaskBounds ? iconErrorStyle : {}; + const positionInputStyle = + isOutOfDatasetBounds || isOutOfTaskBounds ? positionInputErrorStyle : positionInputDefaultStyle; + let maybeErrorMessage = null; + + if (isOutOfDatasetBounds) { + maybeErrorMessage = message["tracing.out_of_dataset_bounds"]; + } else if (!maybeErrorMessage && isOutOfTaskBounds) { + maybeErrorMessage = message["tracing.out_of_task_bounds"]; + } - const rotation = V3.round(getRotation(this.props.flycam)); - const isArbitraryMode = constants.MODES_ARBITRARY.includes(this.props.viewMode); - const positionView = ( -
+ + + + + + + + + + {isArbitraryMode ? ( - + - + - - {isArbitraryMode ? ( - - - - - - - - - ) : null} -
- ); - return ( - - {positionView} - - ); - } -} + ) : null} + + ); -function mapStateToProps(state: WebknossosState): Props { - return { - flycam: state.flycam, - viewMode: state.temporaryConfiguration.viewMode, - dataset: state.dataset, - task: state.task, - }; + return ( + + {positionView} + + ); } -const connector = connect(mapStateToProps); -export default connector(DatasetPositionView); +export default DatasetPositionView; diff --git a/frontend/javascripts/viewer/view/action-bar/save_button.tsx b/frontend/javascripts/viewer/view/action-bar/save_button.tsx index a8a9b26d445..8a5409e3d08 100644 --- a/frontend/javascripts/viewer/view/action-bar/save_button.tsx +++ b/frontend/javascripts/viewer/view/action-bar/save_button.tsx @@ -7,34 +7,21 @@ import { import { Tooltip } from "antd"; import FastTooltip from "components/fast_tooltip"; import ErrorHandling from "libs/error_handling"; +import { useWkSelector } from "libs/react_hooks"; import window from "libs/window"; import _ from "lodash"; -import React from "react"; -import { connect } from "react-redux"; +import React, { useCallback, useState } from "react"; + import { reuseInstanceOnEquality } from "viewer/model/accessors/accessor_helpers"; import { Model } from "viewer/singletons"; import Store, { type SaveState } from "viewer/store"; -import type { WebknossosState } from "viewer/store"; import ButtonComponent from "viewer/view/components/button_component"; -type OwnProps = { +type Props = { onClick: (arg0: React.MouseEvent) => Promise; className?: string; }; -type StateProps = { - progressFraction: number | null | undefined; - isBusy: boolean; -}; -type Props = OwnProps & StateProps; -type State = { - isStateSaved: boolean; - showUnsavedWarning: boolean; - saveInfo: { - outstandingBucketDownloadCount: number; - compressingBucketCount: number; - waitingForCompressionBucketCount: number; - }; -}; + const SAVE_POLLING_INTERVAL = 1000; // 1s const UNSAVED_WARNING_THRESHOLD = 2 * 60 * 1000; // 2 min @@ -51,28 +38,25 @@ const reportUnsavedDurationThresholdExceeded = _.throttle(() => { ); }, REPORT_THROTTLE_THRESHOLD); -class SaveButton extends React.PureComponent { - savedPollingInterval: number = 0; - state: State = { - isStateSaved: false, - showUnsavedWarning: false, - saveInfo: { - outstandingBucketDownloadCount: 0, - compressingBucketCount: 0, - waitingForCompressionBucketCount: 0, - }, - }; - - componentDidMount() { - // Polling can be removed once VolumeMode saving is reactive - this.savedPollingInterval = window.setInterval(this._forceUpdate, SAVE_POLLING_INTERVAL); - } - - componentWillUnmount() { - window.clearInterval(this.savedPollingInterval); - } - - _forceUpdate = () => { +function SaveButton({ onClick, className }: Props) { + const { progressInfo, isBusy } = useWkSelector((state) => state.save); + // For a low action count, the progress info would show only for a very short amount of time. + // Therefore, the progressFraction is set to null, if the count is low. + const progressFraction = + progressInfo.totalActionCount > 5000 + ? progressInfo.processedActionCount / progressInfo.totalActionCount + : null; + const [isStateSaved, setIsStateSaved] = useState(false); + const [showUnsavedWarning, setShowUnsavedWarning] = useState(false); + const [saveInfo, setSaveInfo] = useState({ + outstandingBucketDownloadCount: 0, + compressingBucketCount: 0, + waitingForCompressionBucketCount: 0, + }); + + const getPushQueueStats = useCallback(reuseInstanceOnEquality(Model.getPushQueueStats), []); + + const _forceUpdate = useCallback(() => { const isStateSaved = Model.stateSaved(); const oldestUnsavedTimestamp = getOldestUnsavedTimestamp(Store.getState().save.queue); @@ -86,89 +70,88 @@ class SaveButton extends React.PureComponent { reportUnsavedDurationThresholdExceeded(); } - const newSaveInfo = this.getPushQueueStats(); - this.setState({ - isStateSaved, - showUnsavedWarning, - saveInfo: newSaveInfo, - }); - }; - - getPushQueueStats = reuseInstanceOnEquality(Model.getPushQueueStats); + const newSaveInfo = getPushQueueStats(); + setIsStateSaved(isStateSaved); + setShowUnsavedWarning(showUnsavedWarning); + setSaveInfo(newSaveInfo); + }, [getPushQueueStats]); - getSaveButtonIcon() { - if (this.state.isStateSaved) { + React.useEffect(() => { + // Polling can be removed once VolumeMode saving is reactive + const savedPollingInterval = window.setInterval(_forceUpdate, SAVE_POLLING_INTERVAL); + return () => { + window.clearInterval(savedPollingInterval); + }; + }, [_forceUpdate]); + + const getSaveButtonIcon = () => { + if (isStateSaved) { return ; - } else if (this.props.isBusy) { + } else if (isBusy) { return ; } else { return ; } - } + }; - shouldShowProgress(): boolean { - return this.props.isBusy && this.props.progressFraction != null; - } + const shouldShowProgress = (): boolean => { + return isBusy && progressFraction != null; + }; - render() { - const { progressFraction } = this.props; - const { showUnsavedWarning } = this.state; - const { outstandingBucketDownloadCount } = this.state.saveInfo; - - const totalBucketsToCompress = - this.state.saveInfo.waitingForCompressionBucketCount + - this.state.saveInfo.compressingBucketCount; - return ( - + 0 + ? `${outstandingBucketDownloadCount} items remaining to download...` + : totalBucketsToCompress > 0 + ? `${totalBucketsToCompress} items remaining to compress...` + : null + } > - 0 - ? `${outstandingBucketDownloadCount} items remaining to download...` - : totalBucketsToCompress > 0 - ? `${totalBucketsToCompress} items remaining to compress...` - : null - } - > - {this.shouldShowProgress() ? ( - - {Math.floor((progressFraction || 0) * 100)} % - - ) : ( - Save - )} - - {showUnsavedWarning ? ( - - - - ) : null} - - ); - } + {Math.floor((progressFraction || 0) * 100)} % + + ) : ( + Save + )} + + {showUnsavedWarning ? ( + + + + ) : null} + + ); } function getOldestUnsavedTimestamp(saveQueue: SaveState["queue"]): number | null | undefined { @@ -181,18 +164,4 @@ function getOldestUnsavedTimestamp(saveQueue: SaveState["queue"]): number | null return oldestUnsavedTimestamp; } -function mapStateToProps(state: WebknossosState): StateProps { - const { progressInfo, isBusy } = state.save; - return { - isBusy, - // For a low action count, the progress info would show only for a very short amount of time. - // Therefore, the progressFraction is set to null, if the count is low. - progressFraction: - progressInfo.totalActionCount > 5000 - ? progressInfo.processedActionCount / progressInfo.totalActionCount - : null, - }; -} - -const connector = connect(mapStateToProps); -export default connector(SaveButton); +export default SaveButton; diff --git a/frontend/javascripts/viewer/view/action-bar/tracing_actions_view.tsx b/frontend/javascripts/viewer/view/action-bar/tracing_actions_view.tsx index 24cbf48e706..434a26a4dad 100644 --- a/frontend/javascripts/viewer/view/action-bar/tracing_actions_view.tsx +++ b/frontend/javascripts/viewer/view/action-bar/tracing_actions_view.tsx @@ -42,7 +42,9 @@ import * as Utils from "libs/utils"; import { location } from "libs/window"; import messages from "messages"; import * as React from "react"; -import { connect } from "react-redux"; + +import { useWkSelector } from "libs/react_hooks"; +import { useEffect, useState } from "react"; import { getAntdTheme, getThemeFromUser } from "theme"; import type { APIAnnotationType, APIUser, APIUserBase } from "types/api_types"; import { APIAnnotationTypeEnum, TracingTypeEnum } from "types/api_types"; @@ -67,12 +69,7 @@ import { } from "viewer/model/actions/ui_actions"; import { Model } from "viewer/singletons"; import { api } from "viewer/singletons"; -import type { - BusyBlockingInfo, - RestrictionsAndSettings, - Task, - WebknossosState, -} from "viewer/store"; +import type { RestrictionsAndSettings, Task } from "viewer/store"; import Store from "viewer/store"; import DownloadModalView from "viewer/view/action-bar/download_modal_view"; import MergeModalView from "viewer/view/action-bar/merge_modal_view"; @@ -93,21 +90,10 @@ const AsyncButtonWithAuthentication = withAuthentication { - state: State = { - isReopenAllowed: false, - }; +function TracingActionsView({ layoutMenu }: Props) { + const annotationType = useWkSelector((state) => state.annotation.annotationType); + const annotationId = useWkSelector((state) => state.annotation.annotationId); + const restrictions = useWkSelector((state) => state.annotation.restrictions); + const annotationOwner = useWkSelector((state) => state.annotation.owner); + const task = useWkSelector((state) => state.task); + const activeUser = useWkSelector((state) => state.activeUser); + const hasTracing = useWkSelector( + (state) => state.annotation.skeleton != null || state.annotation.volumes.length > 0, + ); + const isDownloadModalOpen = useWkSelector((state) => state.uiInformation.showDownloadModal); + const isShareModalOpen = useWkSelector((state) => state.uiInformation.showShareModal); + const isRenderAnimationModalOpen = useWkSelector( + (state) => state.uiInformation.showRenderAnimationModal, + ); + const busyBlockingInfo = useWkSelector((state) => state.uiInformation.busyBlockingInfo); + const isAnnotationLockedByUser = useWkSelector((state) => state.annotation.isLockedByOwner); + const isMergeModalOpen = useWkSelector((state) => state.uiInformation.showMergeAnnotationModal); + const isUserScriptsModalOpen = useWkSelector((state) => state.uiInformation.showAddScriptModal); + const isZarrPrivateLinksModalOpen = useWkSelector( + (state) => state.uiInformation.showZarrPrivateLinksModal, + ); - reopenTimeout: ReturnType | null | undefined; + const [isReopenAllowed, setIsReopenAllowed] = useState(false); + const reopenTimeout = React.useRef | null>(null); - componentDidUpdate() { + useEffect(() => { const localStorageEntry = UserLocalStorage.getItem("lastFinishedTask"); - if (this.props.task && localStorageEntry) { + if (task && localStorageEntry) { const { finishedTime } = JSON.parse(localStorageEntry); const timeSinceFinish = Date.now() - finishedTime; const reopenAllowedTime = features().taskReopenAllowedInSeconds * 1000; if (timeSinceFinish < reopenAllowedTime) { - this.setState({ - isReopenAllowed: true, - }); + setIsReopenAllowed(true); - if (this.reopenTimeout != null) { - clearTimeout(this.reopenTimeout); - this.reopenTimeout = null; + if (reopenTimeout.current != null) { + clearTimeout(reopenTimeout.current); + reopenTimeout.current = null; } - this.reopenTimeout = setTimeout(() => { - this.setState({ - isReopenAllowed: false, - }); + reopenTimeout.current = setTimeout(() => { + setIsReopenAllowed(false); UserLocalStorage.removeItem("lastFinishedTask"); - this.reopenTimeout = null; + reopenTimeout.current = null; }, reopenAllowedTime - timeSinceFinish); } } - } - componentWillUnmount() { - if (this.reopenTimeout != null) { - clearTimeout(this.reopenTimeout); - } - } + return () => { + if (reopenTimeout.current != null) { + clearTimeout(reopenTimeout.current); + } + }; + }, [task]); - handleSave = async (event?: React.SyntheticEvent) => { + const handleSave = async (event?: React.MouseEvent) => { if (event != null) { // @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'EventTarge... Remove this comment to see the full error message event.target.blur(); @@ -532,19 +530,16 @@ class TracingActionsView extends React.PureComponent { Model.forceSave(); }; - handleUndo = () => dispatchUndoAsync(Store.dispatch); - handleRedo = () => dispatchRedoAsync(Store.dispatch); + const handleUndo = () => dispatchUndoAsync(Store.dispatch); + const handleRedo = () => dispatchRedoAsync(Store.dispatch); - handleCopyToAccount = async () => { + const handleCopyToAccount = async () => { // duplicates the annotation in the current user account - const newAnnotation = await duplicateAnnotation( - this.props.annotationId, - this.props.annotationType, - ); + const newAnnotation = await duplicateAnnotation(annotationId, annotationType); location.href = `/annotations/${newAnnotation.id}`; }; - handleCopySandboxToAccount = async () => { + const handleCopySandboxToAccount = async () => { const { annotation: sandboxAnnotation, dataset } = Store.getState(); const tracingType = getTracingType(sandboxAnnotation); @@ -577,11 +572,11 @@ class TracingActionsView extends React.PureComponent { location.reload(); }; - handleFinishAndGetNextTask = async () => { + const handleFinishAndGetNextTask = async () => { api.tracing.finishAndGetNextTask(); }; - handleReopenTask = async () => { + const handleReopenTask = async () => { const localStorageEntry = UserLocalStorage.getItem("lastFinishedTask"); if (!localStorageEntry) return; const { annotationId } = JSON.parse(localStorageEntry); @@ -601,21 +596,9 @@ class TracingActionsView extends React.PureComponent { } }; - getTracingViewModals() { + const getTracingViewModals = () => { const { viewMode } = Store.getState().temporaryConfiguration; const isSkeletonMode = Constants.MODES_SKELETON.includes(viewMode); - const { - restrictions, - annotationType, - annotationId, - activeUser, - isZarrPrivateLinksModalOpen, - isUserScriptsModalOpen, - isMergeModalOpen, - isShareModalOpen, - isRenderAnimationModalOpen, - isDownloadModalOpen, - } = this.props; const modals = []; modals.push( @@ -670,162 +653,139 @@ class TracingActionsView extends React.PureComponent { } return modals; - } + }; - render() { - const { - hasTracing, - restrictions, - task, - activeUser, - busyBlockingInfo, - annotationOwner, - layoutMenu, - } = this.props; - - const isAnnotationOwner = activeUser && annotationOwner?.id === activeUser?.id; - const copyAnnotationText = isAnnotationOwner ? "Duplicate" : "Copy To My Account"; - - const saveButton = restrictions.allowUpdate - ? [ - hasTracing - ? [ - -