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 2d53a77f6c..2a02015176 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 a8a9b26d44..8a5409e3d0 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 24cbf48e70..5aace5a3b3 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 { useCallback, 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, + ); + const viewMode = useWkSelector((state) => state.temporaryConfiguration.viewMode); - 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 +531,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 +573,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 +597,8 @@ class TracingActionsView extends React.PureComponent { } }; - getTracingViewModals() { - const { viewMode } = Store.getState().temporaryConfiguration; + const getTracingViewModals = useCallback(() => { 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,151 @@ class TracingActionsView extends React.PureComponent { } return modals; - } + }, [ + activeUser, + isDownloadModalOpen, + isMergeModalOpen, + isZarrPrivateLinksModalOpen, + isShareModalOpen, + isUserScriptsModalOpen, + isRenderAnimationModalOpen, + viewMode, + annotationId, + annotationType, + restrictions, + ]); - 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 - ? [ - -