mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 16:49:41 +00:00
feat: edit duration in cuesheet
This commit is contained in:
committed by
Carlos Valente
parent
c445c42f8a
commit
88b114020b
@@ -15,6 +15,7 @@ export default function useReactiveTextInput(
|
|||||||
options?: {
|
options?: {
|
||||||
submitOnEnter?: boolean;
|
submitOnEnter?: boolean;
|
||||||
submitOnCtrlEnter?: boolean;
|
submitOnCtrlEnter?: boolean;
|
||||||
|
onCancelUpdate?: () => void;
|
||||||
},
|
},
|
||||||
): UseReactiveTextInputReturn {
|
): UseReactiveTextInputReturn {
|
||||||
const [text, setText] = useState<string>(initialText);
|
const [text, setText] = useState<string>(initialText);
|
||||||
@@ -47,7 +48,9 @@ export default function useReactiveTextInput(
|
|||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(valueToSubmit: string) => {
|
(valueToSubmit: string) => {
|
||||||
// No need to update if it hasn't changed
|
// No need to update if it hasn't changed
|
||||||
if (valueToSubmit !== initialText) {
|
if (valueToSubmit === initialText) {
|
||||||
|
options?.onCancelUpdate?.();
|
||||||
|
} else {
|
||||||
const cleanVal = valueToSubmit.trim();
|
const cleanVal = valueToSubmit.trim();
|
||||||
submitCallback(cleanVal);
|
submitCallback(cleanVal);
|
||||||
if (cleanVal !== valueToSubmit) {
|
if (cleanVal !== valueToSubmit) {
|
||||||
@@ -56,7 +59,7 @@ export default function useReactiveTextInput(
|
|||||||
}
|
}
|
||||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
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(() => {
|
const handleEscape = useCallback(() => {
|
||||||
// No need to update if it hasn't changed
|
// No need to update if it hasn't changed
|
||||||
setText(initialText);
|
setText(initialText);
|
||||||
|
options?.onCancelUpdate?.();
|
||||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||||
}, [initialText, ref]);
|
}, [initialText, options, ref]);
|
||||||
|
|
||||||
const keyHandler = useMemo(() => {
|
const keyHandler = useMemo(() => {
|
||||||
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
OntimeRundownEntry,
|
OntimeRundownEntry,
|
||||||
RundownCached,
|
RundownCached,
|
||||||
|
TimeField,
|
||||||
|
TimeStrategy,
|
||||||
TransientEventPayload,
|
TransientEventPayload,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
||||||
@@ -214,13 +216,56 @@ export const useEventAction = () => {
|
|||||||
[updateEvent],
|
[updateEvent],
|
||||||
);
|
);
|
||||||
|
|
||||||
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
|
|
||||||
/**
|
/**
|
||||||
* Updates time of existing event
|
* 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(
|
const updateTimer = useCallback(
|
||||||
async (eventId: string, field: TimeField, value: string) => {
|
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
||||||
const getPreviousEnd = (): number => {
|
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<RundownCached>(RUNDOWN);
|
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||||
|
|
||||||
if (!cachedRundown?.order || !cachedRundown?.rundown) {
|
if (!cachedRundown?.order || !cachedRundown?.rundown) {
|
||||||
@@ -240,34 +285,6 @@ export const useEventAction = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return previousEnd;
|
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],
|
[_updateEventMutation, queryClient],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ declare module '@tanstack/react-table' {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
interface TableMeta<TData extends RowData> {
|
interface TableMeta<TData extends RowData> {
|
||||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
||||||
|
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-6
@@ -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 { Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
@@ -6,19 +6,32 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
|
|||||||
interface SingleLineCellProps {
|
interface SingleLineCellProps {
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
handleUpdate: (newValue: string) => void;
|
handleUpdate: (newValue: string) => void;
|
||||||
|
handleCancelUpdate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(SingleLineCell);
|
const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => {
|
||||||
|
const { initialValue, handleUpdate, handleCancelUpdate } = props;
|
||||||
function SingleLineCell(props: SingleLineCellProps) {
|
|
||||||
const { initialValue, handleUpdate } = props;
|
|
||||||
const ref = useRef<HTMLInputElement | null>(null);
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||||
|
|
||||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||||
|
submitOnEnter: true, // single line should submit on enter
|
||||||
submitOnCtrlEnter: true,
|
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 (
|
return (
|
||||||
<Input
|
<Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -34,4 +47,8 @@ function SingleLineCell(props: SingleLineCellProps) {
|
|||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
SingleLineCell.displayName = 'SingleLineCell';
|
||||||
|
|
||||||
|
export default memo(SingleLineCell);
|
||||||
|
|||||||
+16
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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<HTMLSpanElement> {
|
||||||
|
muted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextLikeInput(props: PropsWithChildren<TextLikeInputProps>) {
|
||||||
|
const { muted, children, className, ...elementProps } = props;
|
||||||
|
const classes = cx([style.textInput, muted && style.muted, className]);
|
||||||
|
return (
|
||||||
|
<div className={classes} {...elementProps} tabIndex={0}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+87
@@ -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<HTMLInputElement>(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 ? (
|
||||||
|
<SingleLineCell
|
||||||
|
ref={inputRef}
|
||||||
|
initialValue={timeString}
|
||||||
|
handleUpdate={handleUpdate}
|
||||||
|
handleCancelUpdate={handleFakeBlur}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextLikeInput onClick={handleFakeFocus} onFocus={handleFakeFocus} muted={!lockedValue}>
|
||||||
|
{duration}
|
||||||
|
</TextLikeInput>
|
||||||
|
);
|
||||||
|
}
|
||||||
+13
-5
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
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 DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||||
import RunningTime from '../../../../features/viewers/common/running-time/RunningTime';
|
import RunningTime from '../../../../features/viewers/common/running-time/RunningTime';
|
||||||
@@ -8,6 +8,7 @@ import { useCuesheetOptions } from '../../cuesheet.options';
|
|||||||
|
|
||||||
import MultiLineCell from './MultiLineCell';
|
import MultiLineCell from './MultiLineCell';
|
||||||
import SingleLineCell from './SingleLineCell';
|
import SingleLineCell from './SingleLineCell';
|
||||||
|
import TimeInputDuration from './TimeInputDuration';
|
||||||
|
|
||||||
import style from '../CuesheetTable.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
@@ -27,11 +28,18 @@ function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEnt
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
|
function MakeDuration({ getValue, row, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
const { hideTableSeconds } = useCuesheetOptions();
|
const update = useCallback(
|
||||||
const cellValue = (getValue() as number | null) ?? 0;
|
(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 <RunningTime value={cellValue} hideSeconds={hideTableSeconds} />;
|
const duration = (getValue() as number | null) ?? 0;
|
||||||
|
const isDurationLocked = (row.original as OntimeEvent)?.timeStrategy === TimeStrategy.LockDuration;
|
||||||
|
return <TimeInputDuration initialValue={duration} onSubmit={update} lockedValue={isDurationLocked} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
|||||||
Reference in New Issue
Block a user