diff --git a/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx b/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx index 1df52f98d..ce24b4f80 100644 --- a/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx +++ b/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx @@ -15,6 +15,7 @@ export default function useReactiveTextInput( options?: { submitOnEnter?: boolean; submitOnCtrlEnter?: boolean; + onCancelUpdate?: () => void; }, ): UseReactiveTextInputReturn { const [text, setText] = useState(initialText); @@ -47,7 +48,9 @@ export default function useReactiveTextInput( const handleSubmit = useCallback( (valueToSubmit: string) => { // No need to update if it hasn't changed - if (valueToSubmit !== initialText) { + if (valueToSubmit === initialText) { + options?.onCancelUpdate?.(); + } else { const cleanVal = valueToSubmit.trim(); submitCallback(cleanVal); if (cleanVal !== valueToSubmit) { @@ -56,7 +59,7 @@ export default function useReactiveTextInput( } setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring }, - [initialText, ref, submitCallback], + [initialText, options, ref, submitCallback], ); /** @@ -66,8 +69,9 @@ export default function useReactiveTextInput( const handleEscape = useCallback(() => { // No need to update if it hasn't changed setText(initialText); + options?.onCancelUpdate?.(); setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring - }, [initialText, ref]); + }, [initialText, options, ref]); const keyHandler = useMemo(() => { const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]]; diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index d094ff9b4..488fa1823 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -7,6 +7,8 @@ import { OntimeEvent, OntimeRundownEntry, RundownCached, + TimeField, + TimeStrategy, TransientEventPayload, } from 'ontime-types'; import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils'; @@ -214,13 +216,56 @@ export const useEventAction = () => { [updateEvent], ); - type TimeField = 'timeStart' | 'timeEnd' | 'duration'; /** * Updates time of existing event + * @param eventId {string} - id of the event + * @param field {TimeField} - field to update + * @param value {string} - new value string to be parsed + * @param lockOnUpdate {boolean} - whether we will apply the lock / release on update */ const updateTimer = useCallback( - async (eventId: string, field: TimeField, value: string) => { - const getPreviousEnd = (): number => { + async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => { + let newValMillis = 0; + + // check for previous keyword + if (value === 'p' || value === 'prev' || value === 'previous') { + newValMillis = getPreviousEnd(); + + // check for adding time keyword + } else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) { + // TODO: is this logic solid? + const remainingString = value.substring(1); + newValMillis = getPreviousEnd() + parseUserTime(remainingString); + } else { + newValMillis = parseUserTime(value); + } + + // dont allow timer values over 23:59:59 + const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND); + const newEvent = { + id: eventId, + [field]: cappedMillis, + }; + + // check if we should lock the field + if (lockOnUpdate) { + if (field === 'timeEnd') { + newEvent.timeStrategy = TimeStrategy.LockEnd; + } else if (field === 'duration') { + newEvent.timeStrategy = TimeStrategy.LockDuration; + } else if (field === 'timeStart' && value === '') { + // if user removes the time start, we should link to the previous + newEvent.linkStart = 'true'; + } + } + + try { + await _updateEventMutation.mutateAsync(newEvent); + } catch (error) { + logAxiosError('Error updating event', error); + } + + function getPreviousEnd(): number { const cachedRundown = queryClient.getQueryData(RUNDOWN); if (!cachedRundown?.order || !cachedRundown?.rundown) { @@ -240,34 +285,6 @@ export const useEventAction = () => { } } return previousEnd; - }; - - let newValMillis = 0; - - // check for previous keyword - if (value === 'p' || value === 'prev' || value === 'previous') { - newValMillis = getPreviousEnd(); - - // check for adding time keyword - } else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) { - // TODO: is this logic solid? - const remainingString = value.substring(1); - newValMillis = getPreviousEnd() + parseUserTime(remainingString); - } else { - newValMillis = parseUserTime(value); - } - - // dont allow timer values over 23:59:59 - const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND); - - const newEvent = { - id: eventId, - [field]: cappedMillis, - }; - try { - await _updateEventMutation.mutateAsync(newEvent); - } catch (error) { - logAxiosError('Error updating event', error); } }, [_updateEventMutation, queryClient], diff --git a/apps/client/src/declarations/declaration.d.ts b/apps/client/src/declarations/declaration.d.ts index e3f681c3a..dd5531a05 100644 --- a/apps/client/src/declarations/declaration.d.ts +++ b/apps/client/src/declarations/declaration.d.ts @@ -27,6 +27,7 @@ declare module '@tanstack/react-table' { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface TableMeta { handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void; + handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void; } } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx index 7f1e1f254..46444264f 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useRef } from 'react'; +import { forwardRef, memo, useCallback, useImperativeHandle, useRef } from 'react'; import { Input } from '@chakra-ui/react'; import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput'; @@ -6,19 +6,32 @@ import useReactiveTextInput from '../../../../common/components/input/text-input interface SingleLineCellProps { initialValue: string; handleUpdate: (newValue: string) => void; + handleCancelUpdate?: () => void; } -export default memo(SingleLineCell); - -function SingleLineCell(props: SingleLineCellProps) { - const { initialValue, handleUpdate } = props; +const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => { + const { initialValue, handleUpdate, handleCancelUpdate } = props; const ref = useRef(null); const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]); const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, { + submitOnEnter: true, // single line should submit on enter submitOnCtrlEnter: true, + onCancelUpdate: handleCancelUpdate, }); + // expose a subset of the methods to the parent + useImperativeHandle(inputRef, () => { + return { + focus() { + ref.current?.focus(); + }, + select() { + ref.current?.select(); + }, + }; + }, [ref]); + return ( ); -} +}); + +SingleLineCell.displayName = 'SingleLineCell'; + +export default memo(SingleLineCell); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput.module.scss new file mode 100644 index 000000000..80fe80196 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput.module.scss @@ -0,0 +1,16 @@ +/* element attempts matching input styles */ +.textInput { + padding-top: 0.25rem; + height: 2rem; + background-color: transparent; + border-radius: 3px; + + &.muted { + color: $label-gray; + } + + &:hover { + background-color: $gray-1100; + cursor: text; + } +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput.tsx new file mode 100644 index 000000000..34668d5fa --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput.tsx @@ -0,0 +1,21 @@ +import { HTMLAttributes, memo, PropsWithChildren } from 'react'; + +import { cx } from '../../../../common/utils/styleUtils'; + +import style from './TextLikeInput.module.scss'; + +export default memo(TextLikeInput); + +interface TextLikeInputProps extends HTMLAttributes { + muted?: boolean; +} + +function TextLikeInput(props: PropsWithChildren) { + const { muted, children, className, ...elementProps } = props; + const classes = cx([style.textInput, muted && style.muted, className]); + return ( +
+ {children} +
+ ); +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TimeInputDuration.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TimeInputDuration.tsx new file mode 100644 index 000000000..70ba3a4c2 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TimeInputDuration.tsx @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { millisToString, parseUserTime } from 'ontime-utils'; + +import { formatDuration } from '../../../../common/utils/time'; + +import SingleLineCell from './SingleLineCell'; +import TextLikeInput from './TextLikeInput'; + +interface TimeInputDurationProps { + initialValue: number; + lockedValue: boolean; + onSubmit: (value: string) => void; +} + +export default function TimeInputDuration(props: TimeInputDurationProps) { + const { initialValue, lockedValue, onSubmit } = props; + + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(initialValue); + const inputRef = useRef(null); + + // when we go into edit mode, set focus to the input + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + // reset value when initialValue changes, avoiding interrupting the user if we are in edit mode + useEffect(() => { + if (!isEditing) { + setValue(initialValue); + } + }, [initialValue, isEditing]); + + const handleFakeFocus = () => setIsEditing(true); + const handleFakeBlur = () => setIsEditing(false); + + const handleUpdate = useCallback( + (newValue: string) => { + setIsEditing(false); + + // Check if there is anything there + if (newValue === '') { + return; + } + + // TODO: is this valid in the duration input? + // we dont know the values in the rundown, escalate to handler + if (newValue.startsWith('p') || newValue.startsWith('+')) { + onSubmit(newValue); + } + + const valueInMillis = parseUserTime(newValue); + if (valueInMillis < 0 || isNaN(valueInMillis)) { + setValue(initialValue); + return; + } + + if (valueInMillis === initialValue) { + return; + } + + onSubmit(newValue); + setValue(Number(newValue)); + }, + [initialValue, onSubmit], + ); + + // duration times have a special format + const duration = formatDuration(value, false); + const timeString = millisToString(value); + + return isEditing ? ( + + ) : ( + + {duration} + + ); +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx index d919d6ff6..ac755d101 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { CellContext, ColumnDef } from '@tanstack/react-table'; -import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types'; +import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry, TimeStrategy } from 'ontime-types'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; import RunningTime from '../../../../features/viewers/common/running-time/RunningTime'; @@ -8,6 +8,7 @@ import { useCuesheetOptions } from '../../cuesheet.options'; import MultiLineCell from './MultiLineCell'; import SingleLineCell from './SingleLineCell'; +import TimeInputDuration from './TimeInputDuration'; import style from '../CuesheetTable.module.scss'; @@ -27,11 +28,18 @@ function MakeTimer({ getValue, row: { original } }: CellContext) { - const { hideTableSeconds } = useCuesheetOptions(); - const cellValue = (getValue() as number | null) ?? 0; +function MakeDuration({ getValue, row, table }: CellContext) { + const update = useCallback( + (newValue: string) => { + table.options.meta?.handleUpdateTimer(row.original.id, 'duration', newValue); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable + [row.original.id], + ); - return ; + const duration = (getValue() as number | null) ?? 0; + const isDurationLocked = (row.original as OntimeEvent)?.timeStrategy === TimeStrategy.LockDuration; + return ; } function MakeMultiLineField({ row, column, table }: CellContext) {