diff --git a/src/pages/operator/css/Alert.css b/src/pages/operator/css/Alert.css index 6c47cd4d..8fc98de1 100644 --- a/src/pages/operator/css/Alert.css +++ b/src/pages/operator/css/Alert.css @@ -45,11 +45,18 @@ border-color: #d3d6d8; } +.movement-recorder { + color: #41464b; + background-color: #e2e3e5; + border-color: #d3d6d8; +} + .secondary { color: #055160; background-color: #cff4fc; border-color: #b6effb; } + /* The close button */ .closebtn { margin-left: 15px; @@ -66,6 +73,7 @@ .closebtn:hover { color: black; } + .hide { display: none; -} +} \ No newline at end of file diff --git a/src/pages/operator/css/DropZone.css b/src/pages/operator/css/DropZone.css index b47d72bf..33262a0b 100644 --- a/src/pages/operator/css/DropZone.css +++ b/src/pages/operator/css/DropZone.css @@ -46,13 +46,13 @@ } .drop-zone[hidden] { + display: none; flex: 0; width: 0; opacity: 0; margin: 0; padding: 0; border: none; - visibility: hidden; } .drop-zone:hover { diff --git a/src/pages/operator/css/LayoutArea.css b/src/pages/operator/css/LayoutArea.css index da8debc9..2f71635c 100644 --- a/src/pages/operator/css/LayoutArea.css +++ b/src/pages/operator/css/LayoutArea.css @@ -1,7 +1,4 @@ #layout-area { - /* padding: var(--screen-padding); - flex: 1 1 0; */ - padding: var(--screen-padding); flex-direction: column; height: auto; } diff --git a/src/pages/operator/css/MovementRecorder.css b/src/pages/operator/css/MovementRecorder.css index 0d9d8cdc..bdb9acb3 100644 --- a/src/pages/operator/css/MovementRecorder.css +++ b/src/pages/operator/css/MovementRecorder.css @@ -1,10 +1,3 @@ -#movement-recorder-container { - display: flex; - gap: 15px; - align-items: center; - justify-content: center; -} - /* The below buttons' CSS likely gets overridden by the TextToSpeech CSS * for the same class names. It is not an issue now because they use the * same styles, but may be an issue in the future if we change styles for @@ -57,3 +50,323 @@ display: flex; justify-content: center; } + + + +/********** + * New UX * + **********/ + +#movement-recorder-container { + position: relative; + align-self: flex-start; + height: 100%; + --footer-height: 60px; +} + +#movement-recorder-container button { + display: flex; + align-items: center; +} + +.pulse { + animation: pulse-red-bg 2s infinite; +} + +@keyframes pulse-red-bg { + 0% { + background-color: #fa7878; + } + + 50% { + background-color: #ff0000; + } + + 100% { + background-color: #fa7878; + } +} + +.recordings-list { + position: relative; + overflow-y: scroll; + overflow-x: hidden; + padding: 8px 0; + height: calc(100% - var(--footer-height)); +} + +.recordings-list .recording-item { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 18px 10px; +} + +.recordings-list .helper-text-empty-state { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + margin: 0 auto; + max-width: 200px; + height: 100%; + font-size: 16px; + font-weight: 600; + text-align: center; + line-height: 130%; + opacity: 0.5; +} + +#movement-recorder-container .footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px; + width: 100%; + height: var(--footer-height); + background: #ffffff; + border-top: 1px solid #000000; +} + +#movement-recorder-container .footer button.button-record { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 8px 8px 16px; + font-weight: 600; +} + +#movement-recorder-container .footer button.button-record.button-record-record svg { + color: #ff0000; +} + +#movement-recorder-container .footer button.button-record.button-record-start:disabled svg { + color: rgba(16, 16, 16, 0.3); +} + +#movement-recorder-container .footer button.button-record.button-record-start svg { + color: #ff0000; +} + +#movement-recorder-container .footer button.button-record.button-record-stop svg { + color: #ff0000; +} + +.naming-modal input, +.footer input { + display: flex; + width: calc(100% - 20px); + padding: 12px 9px; + margin: 0 10px 0 0; + border-radius: 0.4em; + box-shadow: none; + border: none; + background: #f1f1f1; +} + +.footer .button-scroll-wrapper {} + +.joints-list { + position: absolute; + top: 0; + left: 0; + padding: 20px; + width: 100%; + height: calc(100% - var(--footer-height)); + overflow-y: scroll; + overflow-x: hidden; + background-color: #ffffff; +} + +.joints-list ul.checkbox { + margin: 10px 0 0 0; +} + +.joints-list ul.checkbox.nested { + padding: 0 0 0 29px; + margin: 0px; +} + +.joints-list ul.checkbox label { + top: -6px; + position: relative; + padding: 10px 0; +} + +.naming-modal { + position: absolute; + top: 0; + left: 0; + padding: 20px; + width: 100%; + height: 100%; + background-color: #ffffff; +} + +.joints-list .heading, +.naming-modal .heading { + font-weight: 600; + font-size: 18px; +} + +.joints-list .subheading { + margin: 7px 0 0; + max-width: 227px; + font-size: 14px; + font-weight: 400; + line-height: 133%; +} + +.joints-list button.button-select-all { + margin: 15px 0 5px; + width: 100%; + font-weight: 600; +} + +.naming-modal button { + display: flex; + align-items: center; + gap: 6px; + padding: 8px; + height: 100%; + font-weight: 600; +} + +.recording-name-text-area { + margin: 0 5px 0 0; + padding: 0px 5px; + width: fit-content; + height: 31px; + line-height: 150%; + overflow-wrap: break-word; + word-wrap: break-word; + resize: none; + overflow: hidden; + border: 1px solid transparent; + border-radius: 4px; + background-color: #fafafa; + /* Allow highlighting */ + user-select: unset !important; + -webkit-user-select: unset !important; + -moz-user-select: unset !important; + -ms-user-select: unset !important; + +} + +.recording-name-text-area:disabled { + color: #000000; + user-select: none !important; + -webkit-user-select: none !important; + -moz-user-select: none !important; + -ms-user-select: none !important; + pointer-events: none !important; + -webkit-touch-callout: none !important; +} + +.recording-name-text-area:focus { + background-color: var(--btn-turquoise-ultra-light); + border: 1px solid var(--btn-turquoise); + outline: none; +} + +.recording-name-text-area::selection { + background-color: var(--btn-turquoise-light); +} + + +.recording-item-buttons { + --button-width: 41px; + --transition: all 300ms ease-out; +} + +.recording-item-buttons button { + display: flex; + justify-content: center; + align-items: center; + padding: 0px; + aspect-ratio: 1 / 1; + width: var(--button-width) !important; + min-width: var(--button-width) !important; + max-width: var(--button-width) !important; + transition: var(--transition); + color: var(--btn-blue); + border-radius: 4px; +} + +.recording-item-buttons .button-delete-recording-wrapper { + z-index: 0; + position: relative; +} + +.recording-item-buttons button.button-playback, +.recording-item-buttons button.button-edit { + transition: var(--transition); +} + +.recording-item-buttons button.button-playback.visible, +.recording-item-buttons button.button-edit.visible { + opacity: 1; +} + +.recording-item-buttons button.button-playback.hidden, +.recording-item-buttons button.button-edit.hidden { + opacity: 0; +} + +.recording-item-buttons button.button-edit.editing { + color: #ffffff; + background-color: var(--btn-turquoise); + border: 4px solid var(--btn-turquoise); +} + +.button-delete-recording-wrapper button.button-cancel-deletion { + z-index: 1; + position: absolute; + top: 0; + left: 0; + box-shadow: none; +} + +.recording-item-buttons button.button-cancel-deletion.hidden { + transform: translateX(0%); + opacity: 0; + pointer-events: none; +} + +.recording-item-buttons button.button-cancel-deletion.visible { + transform: translateX(calc(-100% - 5px)); + opacity: 1; +} + +.button-delete-recording-wrapper .helper-text { + z-index: 0; + position: absolute; + top: 0px; + left: 0px; + transition: var(--transition); + color: var(--btn-blue); + font-weight: 600; + transform: translate(-65px, calc(var(--button-width))); + white-space: nowrap; + pointer-events: none; +} + +.button-delete-recording-wrapper .helper-text.hidden { + opacity: 0; +} + +.button-delete-recording-wrapper .helper-text.visible { + transform: translate(-65px, calc(var(--button-width) + 2px)); + opacity: 1; +} + +.button-delete-recording-wrapper button.button-delete { + z-index: 2; + position: relative; +} + +.recording-item-buttons button.button-delete.pulse .button-delete-icon { + color: #ffffff; +} \ No newline at end of file diff --git a/src/pages/operator/css/Operator.css b/src/pages/operator/css/Operator.css index cfb01663..82a6868b 100644 --- a/src/pages/operator/css/Operator.css +++ b/src/pages/operator/css/Operator.css @@ -98,6 +98,10 @@ font-size: large; } +.operator-text-to-speech { + margin-top: 20px; +} + .operator-pose-library.hideLabels, .operator-pose-recorder.hideLabels { width: 32rem; @@ -125,16 +129,18 @@ row-gap: 10px; column-gap: 10px; justify-content: center; - padding-top: 20px; } #operator-body { - display: flex; + display: grid; justify-content: center; flex-flow: row; flex: 1 1 0; grid-column: 1/1; grid-row: 3; + grid-template-columns: 70% 30%; + gap: 16px; + padding: 23px; } /** https://stackoverflow.com/a/40989121 **/ diff --git a/src/pages/operator/css/Panel.css b/src/pages/operator/css/Panel.css index ca9c6030..6bcbaabc 100644 --- a/src/pages/operator/css/Panel.css +++ b/src/pages/operator/css/Panel.css @@ -21,6 +21,11 @@ border: 3px solid var(--btn-blue); flex: 1 1 0; background-color: var(--background-color); + border-radius: 0px 0px var(--radius) var(--radius); +} + +.tabs-content>div { + flex: 1; } /* Header *********************************************************************/ diff --git a/src/pages/operator/css/SpeedControl.css b/src/pages/operator/css/SpeedControl.css index 5537fd56..efc10e51 100644 --- a/src/pages/operator/css/SpeedControl.css +++ b/src/pages/operator/css/SpeedControl.css @@ -1,3 +1,9 @@ +#velocity-control-container { + display: flex; + margin: 0 16px; + max-width: 50%; +} + #velocity-control-container button:first-of-type { border-radius: var(--btn-brdr-radius) 0 0 var(--btn-brdr-radius); } diff --git a/src/pages/operator/tsx/MobileOperator.tsx b/src/pages/operator/tsx/MobileOperator.tsx index a0a93971..76d48018 100644 --- a/src/pages/operator/tsx/MobileOperator.tsx +++ b/src/pages/operator/tsx/MobileOperator.tsx @@ -17,6 +17,7 @@ import { hasBetaTeleopKit, stretchTool, movementRecorderFunctionProvider, + homeTheRobotFunctionProvider, underMapFunctionProvider, underVideoFunctionProvider, } from "."; @@ -65,6 +66,9 @@ export const MobileOperator = (props: { const [isRecording, setIsRecording] = React.useState(); const [depthSensing, setDepthSensing] = React.useState(false); const [showAlert, setShowAlert] = React.useState(true); + const [robotNotHomed, setRobotNotHomed] = + React.useState(false); + const [idxFixedRecordingPlaying, idxFixedRecordingPlayingSet] = React.useState(-1); React.useEffect(() => { setTimeout(function () { @@ -116,20 +120,30 @@ export const MobileOperator = (props: { let remoteStreams = props.remoteStreams; + function showHomeTheRobotGlobalControl(isHomed: boolean) { + setRobotNotHomed(!isHomed); + } + homeTheRobotFunctionProvider.setIsHomedCallback( + showHomeTheRobotGlobalControl + ); + /** State passed from the operator and shared by all components */ const sharedState: SharedState = { customizing: false, - onSelect: () => {}, + onSelect: () => { }, remoteStreams: remoteStreams, selectedPath: "deselected", dropZoneState: { - onDrop: () => {}, + onDrop: () => { }, selectedDefinition: undefined, }, buttonStateMap: buttonStateMap.current, hideLabels: false, hasBetaTeleopKit: hasBetaTeleopKit, stretchTool: stretchTool, + robotNotHomed: robotNotHomed, + idxFixedRecordingPlaying: idxFixedRecordingPlaying, + idxFixedRecordingPlayingSet: idxFixedRecordingPlayingSet, }; function updateScreens() { diff --git a/src/pages/operator/tsx/Operator.tsx b/src/pages/operator/tsx/Operator.tsx index 5ed82096..592335f8 100644 --- a/src/pages/operator/tsx/Operator.tsx +++ b/src/pages/operator/tsx/Operator.tsx @@ -18,6 +18,7 @@ import { homeTheRobotFunctionProvider, hasBetaTeleopKit, stretchTool, + movementRecorderFunctionProvider, } from "."; import { ButtonPadButton, @@ -36,11 +37,11 @@ import { moveInLayout, removeFromLayout, } from "./utils/layout_helpers"; -import { MovementRecorder } from "./layout_components/MovementRecorder"; import { Alert } from "./basic_components/Alert"; import "operator/css/Operator.css"; import { TextToSpeech } from "./layout_components/TextToSpeech"; import { HomeTheRobot } from "./layout_components/HomeTheRobot"; +import { set } from "firebase/database"; /** Operator interface webpage */ export const Operator = (props: { @@ -62,15 +63,19 @@ export const Operator = (props: { ButtonPadButton[] >([]); const [moveBaseState, setMoveBaseState] = React.useState(); + const [playbackPosesState, setPlaybackPosesState] = React.useState(undefined); const [moveToPregraspState, setMoveToPregraspState] = React.useState(); const [showTabletState, setShowTabletState] = React.useState(false); const [robotNotHomed, setRobotNotHomed] = - React.useState(false); - function showHomeTheRobotGlobalControl(isHomed: ActionState) { + React.useState(false); + const [idxFixedRecordingPlaying, idxFixedRecordingPlayingSet] = React.useState(-1); + + function showHomeTheRobotGlobalControl(isHomed: boolean) { setRobotNotHomed(!isHomed); } + const alertTimeoutDuration = 5000; // milliseconds homeTheRobotFunctionProvider.setIsHomedCallback( showHomeTheRobotGlobalControl ); @@ -113,10 +118,26 @@ export const Operator = (props: { if (moveBaseAlertTimeout) clearTimeout(moveBaseAlertTimeout); moveBaseAlertTimeout = setTimeout(() => { setMoveBaseState(undefined); - }, 5000); + }, alertTimeoutDuration); } }, [moveBaseState]); + // Callback for when the move base state is updated (e.g., the ROS2 action returns) + // Used to render alerts to the operator. + function playbackPosesCallback(state: ActionState) { + setPlaybackPosesState(state); + } + movementRecorderFunctionProvider.setOperatorCallback(playbackPosesCallback); + let playbackPosesAlertTimeout: NodeJS.Timeout; + React.useEffect(() => { + if (playbackPosesState && playbackPosesState.alert_type != "info") { + if (playbackPosesAlertTimeout) clearTimeout(playbackPosesAlertTimeout); + playbackPosesAlertTimeout = setTimeout(() => { + setPlaybackPosesState(undefined); + }, alertTimeoutDuration); + } + }, [playbackPosesState]); + // Callback for when the move to pregrasp state is updated (e.g., the ROS2 action returns) // Used to render alerts to the operator. function moveToPregraspStateCallback(state: ActionState) { @@ -132,7 +153,7 @@ export const Operator = (props: { clearTimeout(moveToPregraspAlertTimeout); moveToPregraspAlertTimeout = setTimeout(() => { setMoveToPregraspState(undefined); - }, 5000); + }, alertTimeoutDuration); } }, [moveToPregraspState]); @@ -150,7 +171,7 @@ export const Operator = (props: { if (showTabletAlertTimeout) clearTimeout(showTabletAlertTimeout); showTabletAlertTimeout = setTimeout(() => { setShowTabletState(undefined); - }, 5000); + }, alertTimeoutDuration); } }, [showTabletState]); @@ -298,14 +319,15 @@ export const Operator = (props: { hasBetaTeleopKit: hasBetaTeleopKit, stretchTool: stretchTool, robotNotHomed: robotNotHomed, + playbackPosesState: playbackPosesState, + idxFixedRecordingPlaying: idxFixedRecordingPlaying, + idxFixedRecordingPlayingSet: idxFixedRecordingPlayingSet, }; /** Properties for the global options area of the sidebar */ const globalOptionsProps: GlobalOptionsProps = { - displayMovementRecorder: layout.current.displayMovementRecorder, displayTextToSpeech: layout.current.displayTextToSpeech, displayLabels: layout.current.displayLabels, - setDisplayMovementRecorder: setDisplayMovementRecorder, setDisplayTextToSpeech: setDisplayTextToSpeech, setDisplayLabels: setDisplayLabels, defaultLayouts: Object.keys(DEFAULT_LAYOUTS), @@ -313,8 +335,8 @@ export const Operator = (props: { loadLayout: (layoutName: string, dflt: boolean) => { layout.current = dflt ? props.storageHandler.loadDefaultLayout( - layoutName as DefaultLayoutName - ) + layoutName as DefaultLayoutName + ) : props.storageHandler.loadCustomLayout(layoutName); updateLayout(); }, @@ -377,13 +399,28 @@ export const Operator = (props: { {buttonCollision.length > 0 ? buttonCollision.join(", ") + - " in collision!" + " in collision!" : ""} } + {playbackPosesState && ( +
+
+ +
+
+ )} {moveBaseState && (
)}
-
+ {children} +
+ ) +} + +export default Flex \ No newline at end of file diff --git a/src/pages/operator/tsx/default_layouts/SIMPLE_LAYOUT.tsx b/src/pages/operator/tsx/default_layouts/SIMPLE_LAYOUT.tsx index 98acc593..87aeacd8 100644 --- a/src/pages/operator/tsx/default_layouts/SIMPLE_LAYOUT.tsx +++ b/src/pages/operator/tsx/default_layouts/SIMPLE_LAYOUT.tsx @@ -93,13 +93,10 @@ export const BASIC_LAYOUT: LayoutDefinition = { children: [ { type: ComponentType.SingleTab, - label: "Safety", + label: "Movement Recorder", children: [ { - type: ComponentType.RunStopButton, - }, - { - type: ComponentType.BatteryGuage, + type: ComponentType.MovementRecorder, }, ], }, diff --git a/src/pages/operator/tsx/function_providers/MovementRecorderFunctionProvider.tsx b/src/pages/operator/tsx/function_providers/MovementRecorderFunctionProvider.tsx index caf9bb99..116c6de1 100644 --- a/src/pages/operator/tsx/function_providers/MovementRecorderFunctionProvider.tsx +++ b/src/pages/operator/tsx/function_providers/MovementRecorderFunctionProvider.tsx @@ -3,13 +3,20 @@ import { MovementRecorderFunctions, MovementRecorderFunction, } from "../layout_components/MovementRecorder"; -import { RobotPose, ValidJoints } from "shared/util"; +import { ActionState, RobotPose, ValidJoints } from "shared/util"; +import { movementStatesTerminal } from "robot/tsx/robot"; import { StorageHandler } from "../storage_handler/StorageHandler"; export class MovementRecorderFunctionProvider extends FunctionProvider { private recordPosesHeartbeat?: number; // ReturnType private poses: RobotPose[]; private storageHandler: StorageHandler; + private playbackComplete: boolean; + + /** + * Callback function to update the move base state in the operator + */ + private operatorCallback?: (state: ActionState) => void = undefined; constructor(storageHandler: StorageHandler) { super(); @@ -18,6 +25,13 @@ export class MovementRecorderFunctionProvider extends FunctionProvider { this.storageHandler = storageHandler; } + public setPlaybackPosesState(state: ActionState) { + if (movementStatesTerminal.includes(state.alert_type)) { + this.playbackComplete = true + }; + if (this.operatorCallback) this.operatorCallback(state); + } + public provideFunctions(poseRecordFunction: MovementRecorderFunction) { switch (poseRecordFunction) { case MovementRecorderFunction.Record: @@ -53,7 +67,7 @@ export class MovementRecorderFunctionProvider extends FunctionProvider { if ( Math.abs( currentPose[key as ValidJoints]! - - prevPose[key as ValidJoints]!, + prevPose[key as ValidJoints]!, ) > 0.025 ) { // If there is no prevJoint or the current joint moving has changed @@ -61,9 +75,9 @@ export class MovementRecorderFunctionProvider extends FunctionProvider { prevJoint = key as ValidJoints; prevJointDirection = Math.sign( currentPose[key as ValidJoints] - - prevPose[ - prevJoint as ValidJoints - ], + prevPose[ + prevJoint as ValidJoints + ], ); this.poses.push(currentPose); return; @@ -71,7 +85,7 @@ export class MovementRecorderFunctionProvider extends FunctionProvider { currJointDirection = Math.sign( currentPose[key as ValidJoints] - - prevPose[prevJoint as ValidJoints], + prevPose[prevJoint as ValidJoints], ); // If the direction of joint movement has not been changed @@ -145,8 +159,28 @@ export class MovementRecorderFunctionProvider extends FunctionProvider { let recording = this.storageHandler.getRecording(name); FunctionProvider.remoteRobot?.playbackPoses(recording); }; + case MovementRecorderFunction.RenameRecording: + return (recordingID: number, recordingNameNew: string) => { + let recordingNames = this.storageHandler.getRecordingNames(); + // Grab poses from old recording + const poses = this.storageHandler.getRecording(recordingNames[recordingID]) + // Delete old recording + this.storageHandler.deleteRecording(recordingNames[recordingID]); + // Save with new name + this.storageHandler.savePoseRecording(recordingNameNew, poses); + }; case MovementRecorderFunction.Cancel: return () => FunctionProvider.remoteRobot?.stopTrajectory(); } } -} + + /** + * Sets the local pointer to the operator's callback function, to be called + * whenever the playback poses state changes. + * + * @param callback operator's callback function to playback poses state + */ + public setOperatorCallback(callback: (state: ActionState) => void) { + this.operatorCallback = callback; + } +} \ No newline at end of file diff --git a/src/pages/operator/tsx/index.tsx b/src/pages/operator/tsx/index.tsx index c2f10f2d..db6828c4 100644 --- a/src/pages/operator/tsx/index.tsx +++ b/src/pages/operator/tsx/index.tsx @@ -205,6 +205,10 @@ function handleWebRTCMessage(message: WebRTCMessage | WebRTCMessage[]) { console.log("moveBaseState", message.message); underMapFunctionProvider.setMoveBaseState(message.message); break; + case "playbackPosesState": + console.log("playbackPosesState", message.message); + movementRecorderFunctionProvider.setPlaybackPosesState(message.message); + break; case "moveToPregraspState": console.log("moveToPregraspState", message.message); underVideoFunctionProvider.setMoveToPregraspState(message.message); diff --git a/src/pages/operator/tsx/layout_components/CustomizableComponent.tsx b/src/pages/operator/tsx/layout_components/CustomizableComponent.tsx index 6ee8a2ff..a2bea939 100644 --- a/src/pages/operator/tsx/layout_components/CustomizableComponent.tsx +++ b/src/pages/operator/tsx/layout_components/CustomizableComponent.tsx @@ -13,8 +13,7 @@ import { ButtonStateMap } from "../function_providers/ButtonFunctionProvider"; import { ButtonGrid } from "./ButtonGrid"; import { VirtualJoystick } from "./VirtualJoystick"; import { Map } from "./Map"; -import { RunStopButton } from "../static_components/RunStop"; -import { BatteryGuage } from "../static_components/BatteryGauge"; +import { MovementRecorder } from "../layout_components/MovementRecorder"; /** State required for all elements */ export type SharedState = { @@ -37,6 +36,15 @@ export type SharedState = { stretchTool: StretchTool; /** Whether or not robot has been homed */ robotNotHomed: boolean; + /** Movement recorder's playback state */ + playbackPosesState?: undefined | { + state: string; + alert_type: string; + }; + /** Index of the recording in LocalStorage that's being played back */ + idxFixedRecordingPlaying: number; + /** Set value of "idxFixedRecordingPlaying" */ + idxFixedRecordingPlayingSet: React.Dispatch>; }; /** Properties for any of the customizable components: tabs, video streams, or @@ -64,6 +72,7 @@ export type CustomizableComponentProps = { * @returns rendered component */ export const CustomizableComponent = (props: CustomizableComponentProps) => { + if (!props.definition.type) { throw new Error(`Component at ${props.path} is missing type`); } @@ -84,10 +93,8 @@ export const CustomizableComponent = (props: CustomizableComponentProps) => { return ; case ComponentType.Map: return ; - case ComponentType.RunStopButton: - return ; - case ComponentType.BatteryGuage: - return ; + case ComponentType.MovementRecorder: + return ; default: throw Error( `CustomizableComponent cannot render component of unknown type: ${props.definition.type}\nYou may need to add a case for this component in the switch statement in CustomizableComponent.` diff --git a/src/pages/operator/tsx/layout_components/MovementRecorder.tsx b/src/pages/operator/tsx/layout_components/MovementRecorder.tsx index ccc40334..b83f10f6 100644 --- a/src/pages/operator/tsx/layout_components/MovementRecorder.tsx +++ b/src/pages/operator/tsx/layout_components/MovementRecorder.tsx @@ -1,16 +1,24 @@ -import React, { useEffect, useState } from "react"; -import { PopupModal } from "../basic_components/PopupModal"; +import React, { useEffect, useCallback, useState, useRef } from "react"; +import Flex from "../basic_components/Flex"; +import PlayCircle from "@mui/icons-material/PlayCircle"; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import StopCircleIcon from '@mui/icons-material/StopCircle'; +import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; +import NotStartedIcon from '@mui/icons-material/NotStarted'; +import SearchIcon from '@mui/icons-material/Search'; +import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import LocalFloristIcon from '@mui/icons-material/LocalFlorist'; + +import { movementStatesTerminal, movementStatesTransitory } from "robot/tsx/robot"; +import { CustomizableComponentProps } from "./CustomizableComponent"; +import { ActionState } from "shared/util"; import { movementRecorderFunctionProvider } from "operator/tsx/index"; -import { Dropdown } from "../basic_components/Dropdown"; -import { Tooltip } from "../static_components/Tooltip"; import "operator/css/MovementRecorder.css"; import "operator/css/basic_components.css"; -import { isMobile } from "react-device-detect"; -import { RadioFunctions, RadioGroup } from "../basic_components/RadioGroup"; -import PlayCircle from "@mui/icons-material/PlayCircle"; -import RadioButtonCheckedIcon from "@mui/icons-material/RadioButtonChecked"; -import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; -import SaveIcon from "@mui/icons-material/Save"; + /** All the possible button functions */ export enum MovementRecorderFunction { @@ -20,9 +28,10 @@ export enum MovementRecorderFunction { SavedRecordingNames, DeleteRecording, LoadRecording, + LoadRecordingName, Cancel, DeleteRecordingName, - LoadRecordingName, + RenameRecording, } export interface MovementRecorderFunctions { @@ -40,13 +49,318 @@ export interface MovementRecorderFunctions { SavedRecordingNames: () => string[]; DeleteRecording: (recordingID: number) => void; LoadRecording: (recordingID: number) => void; + RenameRecording: (recordingID: number, recordingNameNew: string) => void; + Cancel: () => void; +} + + + +/**************************** + * Record/Start/Stop + ); + } + //////////////////////// + //// "Start" button //// + //////////////////////// + else if ( + props.showRecordingStartButton + && !props.isRecording + ) { + return ( + + ); + } + /////////////////////// + //// "Stop" button //// + /////////////////////// + else if (props.isRecording) { + return ( + + ); + } } -export const MovementRecorder = (props: { - hideLabels: boolean; - globalRecord?: boolean; - isRecording?: boolean; +const ButtonFilter = (props: { + isFilterActivated: boolean; + isFilterActivatedSet: React.Dispatch>; + filterQuery: string; + filterQuerySet: React.Dispatch>; }) => { + + // Reference to focus the input when filter is activated + const refInput = useRef(null); + + // Effect to focus the input when filter is activated + useEffect(() => { + if (props.isFilterActivated && refInput.current) { + requestAnimationFrame(() => { + refInput.current?.focus(); + }); + } + }, [props.isFilterActivated]); + + if (!props.isFilterActivated) { + return ( + + ); + } else return ( + e.target.select()} + onChange={(e) => props.filterQuerySet(e.target.value)} + /> + ); +} + +interface RecordingItemProps { + recordingName: string; + idxFixed: number; + functions: { + LoadRecording: (idx: number) => void; + RenameRecording: (idx: number, newName: string) => void; + DeleteRecording: (idx: number) => void; + SavedRecordingNames: () => string[]; + Cancel: () => void; + }; + setRecordings: React.Dispatch>; + scrollToTop?: () => void; + playbackPosesState: ActionState | undefined; + idxFixedRecordingPlaying: number; + idxFixedRecordingPlayingSet: React.Dispatch>; +} + +const RecordingItem: React.FC = ({ + recordingName, + idxFixed, + functions, + setRecordings, + scrollToTop, + playbackPosesState, + idxFixedRecordingPlaying, + idxFixedRecordingPlayingSet, +}: RecordingItemProps) => { + const [valueTextArea, valueTextAreaSet] = useState(recordingName); + const refTextArea = useRef(null); + const recordingsRefresh = useCallback(() => { + setRecordings(functions.SavedRecordingNames()); + }, []); + const [isEditing, isEditingSet] = useState(false); + const [isAskingConfirmationBeforeDelete, isAskingConfirmationBeforeDeleteSet] = useState(false); + const isRecordingPlaying = movementStatesTransitory.includes(playbackPosesState?.state); + const isThisPlaying = idxFixedRecordingPlaying === idxFixed; + const isDisabled = isRecordingPlaying && !isThisPlaying; + + + // Focus