mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 10:53:51 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67ebd12a94 | |||
| e963e183db | |||
| ac4257ece9 | |||
| 8e111512d8 |
@@ -34,7 +34,9 @@
|
|||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"web-vitals": "^3.1.1",
|
"web-vitals": "^3.1.1",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3",
|
||||||
|
"lexical": "^0.17.0",
|
||||||
|
"@lexical/react": "^0.17.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||||
|
|||||||
@@ -79,3 +79,22 @@
|
|||||||
border-color: $gray-1250;
|
border-color: $gray-1250;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ghosted {
|
||||||
|
background: transparent;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: $opacity-disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
import { ButtonHTMLAttributes } from 'react';
|
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||||
|
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './Button.module.scss';
|
import style from './Button.module.scss';
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive';
|
variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive' | 'ghosted';
|
||||||
size?: 'small' | 'medium' | 'large' | 'xlarge';
|
size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||||
fluid?: boolean;
|
fluid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button(props: ButtonProps) {
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
||||||
const { className, children, variant = 'subtle', size = 'medium', fluid, ...buttonProps } = props;
|
const { className, children, variant = 'subtle', size = 'medium', fluid, ...buttonProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={ref}
|
||||||
className={cx([style.baseButton, style[variant], style[size], fluid && style.fluid, className])}
|
className={cx([style.baseButton, style[variant], style[size], fluid && style.fluid, className])}
|
||||||
type='button'
|
type='button'
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
@@ -22,4 +23,8 @@ export default function Button(props: ButtonProps) {
|
|||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export default Button;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
place-content: center;
|
place-content: center;
|
||||||
|
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 3px;
|
border-radius: $component-border-radius-md;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
height: 1.5rem;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
.medium {
|
.medium {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { cx } from '../../utils/styleUtils';
|
|||||||
import style from './IconButton.module.scss';
|
import style from './IconButton.module.scss';
|
||||||
|
|
||||||
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive';
|
variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive' | 'ghosted';
|
||||||
size?: 'medium' | 'large' | 'xlarge';
|
size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IconButton({
|
export default function IconButton({
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IconBaseProps } from 'react-icons';
|
||||||
|
import { IoLink } from 'react-icons/io5';
|
||||||
|
|
||||||
|
export default function RotatedLink(linkProps: IconBaseProps) {
|
||||||
|
return <IoLink style={{ transform: 'rotate(-45deg)' }} {...linkProps} />;
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ $input-font-size: 15px;
|
|||||||
|
|
||||||
.inputField {
|
.inputField {
|
||||||
font-size: $input-font-size;
|
font-size: $input-font-size;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.5px;
|
||||||
max-width: 7em;
|
max-width: 7em;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
color: $ontime-delay-text
|
color: $ontime-delay-text
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
|
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
|
||||||
|
|
||||||
interface UseReactiveTextInputReturn {
|
interface UseReactiveTextInputReturn {
|
||||||
@@ -21,6 +21,8 @@ export default function useReactiveTextInput(
|
|||||||
},
|
},
|
||||||
): UseReactiveTextInputReturn {
|
): UseReactiveTextInputReturn {
|
||||||
const [text, setText] = useState<string>(initialText);
|
const [text, setText] = useState<string>(initialText);
|
||||||
|
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
|
||||||
|
const isKeyboardSubmitting = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof initialText === 'undefined') {
|
if (typeof initialText === 'undefined') {
|
||||||
@@ -99,11 +101,25 @@ export default function useReactiveTextInput(
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (options?.submitOnEnter) {
|
if (options?.submitOnEnter) {
|
||||||
hotKeys.push(['Enter', () => handleSubmit(text)]);
|
hotKeys.push(['Enter', () => {
|
||||||
|
isKeyboardSubmitting.current = true;
|
||||||
|
handleSubmit(text);
|
||||||
|
// clear flag after blur has been processed
|
||||||
|
setTimeout(() => {
|
||||||
|
isKeyboardSubmitting.current = false;
|
||||||
|
}, 0);
|
||||||
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.submitOnCtrlEnter) {
|
if (options?.submitOnCtrlEnter) {
|
||||||
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
|
hotKeys.push(['mod + Enter', () => {
|
||||||
|
isKeyboardSubmitting.current = true;
|
||||||
|
handleSubmit(text);
|
||||||
|
// clear flag after blur has been processed
|
||||||
|
setTimeout(() => {
|
||||||
|
isKeyboardSubmitting.current = false;
|
||||||
|
}, 0);
|
||||||
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
||||||
@@ -126,7 +142,11 @@ export default function useReactiveTextInput(
|
|||||||
return {
|
return {
|
||||||
value: text,
|
value: text,
|
||||||
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
||||||
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
|
onBlur: (event: ChangeEvent) => {
|
||||||
|
if (!isKeyboardSubmitting.current) {
|
||||||
|
handleSubmit((event.target as HTMLInputElement).value);
|
||||||
|
}
|
||||||
|
},
|
||||||
onKeyDown: keyHandler,
|
onKeyDown: keyHandler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.timeInput {
|
.timeInput {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 7.5em;
|
max-width: 7.5em;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.5px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
|
||||||
|
z-index: $zindex-dialog;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
padding-inline: 1rem;
|
||||||
|
min-width: min(680px, 90vw);
|
||||||
|
min-height: min(200px, 10vh);
|
||||||
|
|
||||||
|
background-color: $gray-1250;
|
||||||
|
color: $ui-white;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: $box-shadow-l1;
|
||||||
|
border: 1px solid $gray-1200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: $zindex-backdrop;
|
||||||
|
background-color: $backdrop-color;
|
||||||
|
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||||
|
|
||||||
|
&[data-starting-style],
|
||||||
|
&[data-ending-style] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-block: 1rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
max-height: min(80vh, 600px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding-block: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { IoClose } from 'react-icons/io5';
|
||||||
|
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
|
||||||
|
|
||||||
|
import IconButton from '../buttons/IconButton';
|
||||||
|
|
||||||
|
import style from './Modal.module.scss';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
showBackdrop?: boolean;
|
||||||
|
bodyElements: ReactNode;
|
||||||
|
footerElements?: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
showCloseButton,
|
||||||
|
showBackdrop,
|
||||||
|
bodyElements,
|
||||||
|
footerElements,
|
||||||
|
onClose,
|
||||||
|
}: ModalProps) {
|
||||||
|
return (
|
||||||
|
<BaseDialog.Root
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) onClose();
|
||||||
|
}}
|
||||||
|
dismissible={false}
|
||||||
|
>
|
||||||
|
<BaseDialog.Portal>
|
||||||
|
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
|
||||||
|
<BaseDialog.Popup className={style.modal}>
|
||||||
|
<div className={style.title}>
|
||||||
|
{title}
|
||||||
|
{showCloseButton && (
|
||||||
|
<IconButton variant='subtle-white' onClick={onClose}>
|
||||||
|
<IoClose />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.body}>{bodyElements}</div>
|
||||||
|
<div className={style.footer}>{footerElements}</div>
|
||||||
|
</BaseDialog.Popup>
|
||||||
|
</BaseDialog.Portal>
|
||||||
|
</BaseDialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.popup {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
z-index: $zindex-dialog;
|
||||||
|
|
||||||
|
color: $ui-white;
|
||||||
|
background-color: $gray-1250;
|
||||||
|
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: $box-shadow-l1;
|
||||||
|
border: 2px solid $gray-1200;
|
||||||
|
|
||||||
|
//width: 32rem;
|
||||||
|
max-width: 90vw;
|
||||||
|
|
||||||
|
transform-origin: var(--transform-origin);
|
||||||
|
transition:
|
||||||
|
transform 150ms,
|
||||||
|
opacity 150ms;
|
||||||
|
|
||||||
|
&[data-starting-style],
|
||||||
|
&[data-ending-style] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
import { Popover } from '@base-ui-components/react/popover';
|
||||||
|
|
||||||
|
import style from './Popover.module.scss';
|
||||||
|
|
||||||
|
interface PopoverContentsProps extends Popover.Positioner.Props {
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
export default function PopoverContents({
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...popoverProps
|
||||||
|
}: PropsWithChildren<PopoverContentsProps>) {
|
||||||
|
return (
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Positioner sideOffset={8} {...popoverProps}>
|
||||||
|
<Popover.Popup className={style.popup}>
|
||||||
|
{title && <Popover.Title className={style.title}>{title}</Popover.Title>}
|
||||||
|
<Popover.Description className={className}>{children}</Popover.Description>
|
||||||
|
</Popover.Popup>
|
||||||
|
</Popover.Positioner>
|
||||||
|
</Popover.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react';
|
|||||||
|
|
||||||
import ProtectRoute from '../common/components/protect-route/ProtectRoute';
|
import ProtectRoute from '../common/components/protect-route/ProtectRoute';
|
||||||
|
|
||||||
import style from './FeatureWrapper.module.scss';
|
import style from './EditorFeatureWrapper.module.scss';
|
||||||
|
|
||||||
export default function EditorFeatureWrapper({ children }: PropsWithChildren) {
|
export default function EditorFeatureWrapper({ children }: PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export default function BlockEditor({ block }: BlockEditorProps) {
|
|||||||
|
|
||||||
const isEditor = window.location.pathname.includes('editor');
|
const isEditor = window.location.pathname.includes('editor');
|
||||||
const planOffset = typeof block.targetDuration !== 'number' ? null : block.targetDuration - block.duration;
|
const planOffset = typeof block.targetDuration !== 'number' ? null : block.targetDuration - block.duration;
|
||||||
console.log('targetDuration:', block.targetDuration);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.content}>
|
<div className={style.content}>
|
||||||
@@ -75,13 +74,13 @@ export default function BlockEditor({ block }: BlockEditorProps) {
|
|||||||
}
|
}
|
||||||
<Editor.Label>First event start</Editor.Label>
|
<Editor.Label>First event start</Editor.Label>
|
||||||
<TextLikeInput className={style.textLikeInput}>
|
<TextLikeInput className={style.textLikeInput}>
|
||||||
{millisToString(block.startTime, { fallback: timerPlaceholder })}
|
{millisToString(block.timeStart, { fallback: timerPlaceholder })}
|
||||||
</TextLikeInput>
|
</TextLikeInput>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Editor.Label htmlFor='endTime'>Last event end</Editor.Label>
|
<Editor.Label>Last event end</Editor.Label>
|
||||||
<TextLikeInput className={style.textLikeInput}>
|
<TextLikeInput className={style.textLikeInput}>
|
||||||
{millisToString(block.endTime, { fallback: timerPlaceholder })}
|
{millisToString(block.timeEnd, { fallback: timerPlaceholder })}
|
||||||
</TextLikeInput>
|
</TextLikeInput>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,43 +1,50 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
import { isOntimeBlock, isOntimeEvent, OntimeEntry } from 'ontime-types';
|
||||||
|
|
||||||
import useRundown from '../../../common/hooks-query/useRundown';
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
|
||||||
|
|
||||||
|
import BlockEditor from './BlockEditor';
|
||||||
import EventEditor from './EventEditor';
|
import EventEditor from './EventEditor';
|
||||||
|
|
||||||
import style from './EntryEditor.module.scss';
|
import style from './EntryEditor.module.scss';
|
||||||
|
|
||||||
interface CuesheetEventEditorProps {
|
interface CuesheetEntryEditorProps {
|
||||||
eventId: string;
|
entryId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetEventEditor({ eventId }: CuesheetEventEditorProps) {
|
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
|
||||||
const { data } = useRundown();
|
const { data } = useRundown();
|
||||||
|
const [entry, setEntry] = useState<OntimeEntry | null>(null);
|
||||||
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data.order.length === 0) {
|
if (data.order.length === 0) {
|
||||||
setEvent(null);
|
setEntry(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = data.entries[eventId];
|
const event = data.entries[entryId];
|
||||||
if (event && isOntimeEvent(event)) {
|
if (event) {
|
||||||
setEvent(event);
|
setEntry(event);
|
||||||
} else {
|
} else {
|
||||||
setEvent(null);
|
setEntry(null);
|
||||||
}
|
}
|
||||||
}, [eventId, data.order, data.entries]);
|
}, [entryId, data.order, data.entries]);
|
||||||
|
|
||||||
if (!event) {
|
if (isOntimeEvent(entry)) {
|
||||||
return null;
|
return (
|
||||||
|
<div className={style.entryEditor} data-testid='editor-container'>
|
||||||
|
<EventEditor event={entry} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (isOntimeBlock(entry)) {
|
||||||
<div className={cx([style.entryEditor, style.inModal])} data-testid='editor-container'>
|
return (
|
||||||
<EventEditor event={event} />
|
<div className={style.inModal} data-testid='editor-container'>
|
||||||
</div>
|
<BlockEditor block={entry} />
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
||||||
&.inModal {
|
|
||||||
max-height: 80vh;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
|||||||
@@ -132,11 +132,11 @@ export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
<div className={style.metaRow}>
|
<div className={style.metaRow}>
|
||||||
<div className={style.metaEntry}>
|
<div className={style.metaEntry}>
|
||||||
<div>Start</div>
|
<div>Start</div>
|
||||||
<div>{formatTime(data.startTime)}</div>
|
<div>{formatTime(data.timeStart)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.metaEntry}>
|
<div className={style.metaEntry}>
|
||||||
<div>End</div>
|
<div>End</div>
|
||||||
<div>{formatTime(data.endTime)}</div>
|
<div>{formatTime(data.timeEnd)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.metaEntry}>
|
<div className={style.metaEntry}>
|
||||||
<div>Duration</div>
|
<div>Duration</div>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ html,
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* smaller root size for MacOS laptops */
|
/* smaller root size for MacOS laptops */
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ $text-black: $gray-1350;
|
|||||||
// interface panels
|
// interface panels
|
||||||
$bg-container-l1: $gray-1350;
|
$bg-container-l1: $gray-1350;
|
||||||
$bg-container-l2: $gray-1300;
|
$bg-container-l2: $gray-1300;
|
||||||
$bg-container-l3: $gray-1350;
|
|
||||||
|
|
||||||
$bg-container-onlight: $gray-100;
|
$bg-container-onlight: $gray-100;
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,11 @@
|
|||||||
padding-block: 0.5rem 1rem;
|
padding-block: 0.5rem 1rem;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 3rem auto auto 1fr;
|
grid-template-rows: 3.5rem auto auto 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'overview'
|
'overview'
|
||||||
'progress'
|
'progress'
|
||||||
'settings'
|
'settings'
|
||||||
'table';
|
'table';
|
||||||
gap: 1rem;
|
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +1,50 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { IoApps, IoSettingsOutline } from 'react-icons/io5';
|
import { IoApps } from 'react-icons/io5';
|
||||||
import { Modal, ModalContent, ModalOverlay } from '@chakra-ui/react';
|
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
|
||||||
import IconButton from '../../common/components/buttons/IconButton';
|
import IconButton from '../../common/components/buttons/IconButton';
|
||||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||||
import useViewEditor from '../../common/components/navigation-menu/useViewEditor';
|
import useViewEditor from '../../common/components/navigation-menu/useViewEditor';
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||||
import { CuesheetOverview } from '../../features/overview/Overview';
|
import { CuesheetOverview } from '../../features/overview/Overview';
|
||||||
import CuesheetEventEditor from '../../features/rundown/entry-editor/CuesheetEventEditor';
|
|
||||||
|
|
||||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||||
|
import CuesheetEditModal from './cuesheet-edit-modal/CuesheetEditModal';
|
||||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetCols';
|
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||||
import { cuesheetOptions } from './cuesheet.options';
|
|
||||||
|
|
||||||
import styles from './CuesheetPage.module.scss';
|
import styles from './CuesheetPage.module.scss';
|
||||||
|
|
||||||
export default function CuesheetPage() {
|
export default function CuesheetPage() {
|
||||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
||||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable: true });
|
const { isViewLocked } = useViewEditor({ isLockable: true });
|
||||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||||
const [isEventEditorOpen, eventEditorHandler] = useDisclosure();
|
|
||||||
const [eventId, setEventId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
||||||
|
|
||||||
useWindowTitle('Cuesheet');
|
useWindowTitle('Cuesheet');
|
||||||
|
|
||||||
/**
|
const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending';
|
||||||
* Handles setting the edit modal target and visibility
|
|
||||||
*/
|
|
||||||
const setShowModal = useCallback(
|
|
||||||
(eventId: string | null) => {
|
|
||||||
if (eventId) {
|
|
||||||
setEventId(eventId);
|
|
||||||
eventEditorHandler.open();
|
|
||||||
} else {
|
|
||||||
setEventId(null);
|
|
||||||
eventEditorHandler.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[eventEditorHandler],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending') {
|
|
||||||
return <EmptyPage text='Loading...' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal isOpen={isEventEditorOpen} onClose={eventEditorHandler.close} variant='ontime'>
|
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||||
<ModalOverlay />
|
<CuesheetEditModal />
|
||||||
<ModalContent maxWidth='max(640px, 40vw)' padding='1rem'>
|
|
||||||
<CuesheetEventEditor eventId={eventId!} />
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
|
||||||
<ViewParamsEditor viewOptions={cuesheetOptions} />
|
|
||||||
<CuesheetOverview>
|
<CuesheetOverview>
|
||||||
<IconButton
|
{!isViewLocked && (
|
||||||
aria-label='Toggle navigation'
|
<IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={menuHandler.open}>
|
||||||
variant='subtle-white'
|
<IoApps />
|
||||||
size='xlarge'
|
</IconButton>
|
||||||
onClick={menuHandler.open}
|
)}
|
||||||
disabled={isViewLocked}
|
|
||||||
>
|
|
||||||
<IoApps />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
aria-label='Toggle settings'
|
|
||||||
variant='subtle-white'
|
|
||||||
size='xlarge'
|
|
||||||
onClick={showEditFormDrawer}
|
|
||||||
disabled={isViewLocked}
|
|
||||||
>
|
|
||||||
<IoSettingsOutline />
|
|
||||||
</IconButton>
|
|
||||||
</CuesheetOverview>
|
</CuesheetOverview>
|
||||||
<CuesheetProgress />
|
<CuesheetProgress />
|
||||||
<CuesheetDnd columns={columns}>
|
<CuesheetDnd columns={columns}>
|
||||||
<CuesheetTable data={flatRundown} columns={columns} showModal={setShowModal} />
|
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable data={flatRundown} columns={columns} />}
|
||||||
</CuesheetDnd>
|
</CuesheetDnd>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
import Modal from '../../../common/components/modal/Modal';
|
||||||
|
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||||
|
|
||||||
|
import { useCuesheetEditModal } from './useCuesheetEditModal';
|
||||||
|
|
||||||
|
export default memo(CuesheetEditModal);
|
||||||
|
function CuesheetEditModal() {
|
||||||
|
const entryId = useCuesheetEditModal((state) => state.selectedEntryId);
|
||||||
|
const closeModal = useCuesheetEditModal((state) => state.clearSelection);
|
||||||
|
|
||||||
|
if (entryId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen
|
||||||
|
onClose={closeModal}
|
||||||
|
title='Edit entry'
|
||||||
|
showCloseButton
|
||||||
|
bodyElements={<CuesheetEntryEditor entryId={entryId} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { EntryId } from 'ontime-types';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface SelectedEntryState {
|
||||||
|
selectedEntryId: EntryId | null;
|
||||||
|
setEditableEntry: (entryId: EntryId) => void;
|
||||||
|
clearSelection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCuesheetEditModal = create<SelectedEntryState>((set) => ({
|
||||||
|
selectedEntryId: null,
|
||||||
|
setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }),
|
||||||
|
clearSelection: () => set({ selectedEntryId: null }),
|
||||||
|
}));
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
.progressOverride {
|
.progressOverride {
|
||||||
|
margin-top: 0.5rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
grid-area: progress;
|
grid-area: progress;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,25 +8,41 @@ $table-header-font-size: calc(1rem - 2px);
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-bottom: 640px; // allow focus to reach last elements
|
padding-bottom: 70vh; // allow focus to reach last elements
|
||||||
}
|
}
|
||||||
|
|
||||||
.cuesheet {
|
.cuesheet {
|
||||||
font-size: $table-font-size;
|
font-size: $table-font-size;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: $gray-1300;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.resizer {
|
||||||
|
width: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
margin-left: 4px; // compensate left border
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
margin: 1px;
|
margin: 1px;
|
||||||
font-weight: inherit;
|
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
position: relative;
|
position: relative;
|
||||||
@include ellipsis-overflow;
|
padding: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
th:focus,
|
th:focus,
|
||||||
@@ -35,102 +51,44 @@ $table-header-font-size: calc(1rem - 2px);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableHeader,
|
.actionColumn {
|
||||||
.eventRow {
|
padding-inline: 0.5rem;
|
||||||
.actionColumn {
|
background-color: transparent;
|
||||||
width: calc(2rem + 0.5rem); // sm button size (--chakra-sizes-8) + 2 * padding
|
display: flex;
|
||||||
background-color: transparent;
|
align-items: start;
|
||||||
}
|
justify-content: center;
|
||||||
|
line-height: 2rem; // match input height
|
||||||
|
}
|
||||||
|
|
||||||
.indexColumn {
|
.indexColumn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
|
|
||||||
min-width: 3em; // allow for 3-digit numbers
|
min-width: 3em; // allow for 3-digit numbers
|
||||||
font-size: $table-header-font-size;
|
font-size: $table-header-font-size;
|
||||||
background-color: $gray-1300; // will be overridden inline
|
line-height: 2rem; // match input height
|
||||||
}
|
background-color: $gray-1300; // will be overridden inline
|
||||||
|
font-weight: 600;;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableHeader {
|
.tableHeader {
|
||||||
|
line-height: 2rem;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
z-index: $zindex-floating;
|
z-index: $zindex-floating;
|
||||||
background-color: $ui-black;
|
background-color: $ui-black;
|
||||||
font-size: $table-header-font-size;
|
font-size: $table-header-font-size;
|
||||||
color: $label-gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
.actionColumn {
|
||||||
background-color: $gray-1300;
|
|
||||||
padding-left: 0.25rem;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.resizer {
|
|
||||||
width: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.eventRow {
|
|
||||||
vertical-align: top;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
outline: 1px solid $blue-700;
|
|
||||||
outline-offset: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
border-radius: 2px;
|
width: calc(2rem + 1px); // button + padding + margin
|
||||||
padding: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
.indexColumn {
|
||||||
&.skip {
|
width: 3.5em;
|
||||||
text-decoration: line-through;
|
|
||||||
opacity: $opacity-disabled !important; // fighting inline styles
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.blockRow {
|
|
||||||
width: 100%;
|
|
||||||
background-color: $gray-1350;
|
|
||||||
font-size: 1rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
|
|
||||||
td {
|
|
||||||
align-self: flex-end;
|
|
||||||
position: sticky;
|
|
||||||
left: 1rem;
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.delayRow {
|
|
||||||
width: 100%;
|
|
||||||
color: $ontime-delay-text;
|
|
||||||
|
|
||||||
td {
|
|
||||||
position: sticky;
|
|
||||||
left: 47.5%; // center of the screen, ish
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
&:first-letter {
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.check {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delayedTime {
|
|
||||||
color: $ontime-delay-text;
|
|
||||||
font-size: calc(1rem - 2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizer {
|
.resizer {
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
opacity: $opacity-disabled;
|
opacity: $opacity-disabled;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useTableNav } from '@table-nav/react';
|
import { useTableNav } from '@table-nav/react';
|
||||||
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent, TimeField } from 'ontime-types';
|
import { OntimeEntry, TimeField } from 'ontime-types';
|
||||||
|
|
||||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
import useFollowComponent from '../../../common/hooks/useFollowComponent';
|
import useFollowComponent from '../../../common/hooks/useFollowComponent';
|
||||||
import { useCuesheetOptions } from '../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||||
|
|
||||||
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
|
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
|
||||||
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
||||||
@@ -18,21 +18,59 @@ import style from './CuesheetTable.module.scss';
|
|||||||
interface CuesheetTableProps {
|
interface CuesheetTableProps {
|
||||||
data: OntimeEntry[];
|
data: OntimeEntry[];
|
||||||
columns: ColumnDef<OntimeEntry>[];
|
columns: ColumnDef<OntimeEntry>[];
|
||||||
showModal: (eventId: MaybeString) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetTable({ data, columns, showModal }: CuesheetTableProps) {
|
export default function CuesheetTable({ data, columns }: CuesheetTableProps) {
|
||||||
const { updateEntry, updateTimer } = useEntryActions();
|
const { updateEntry, updateTimer } = useEntryActions();
|
||||||
const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
|
const followPlayback = usePersistedCuesheetOptions((state) => state.followPlayback);
|
||||||
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
|
const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
|
||||||
useColumnManager(columns);
|
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
|
||||||
|
|
||||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
||||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followPlayback });
|
||||||
|
|
||||||
const { listeners } = useTableNav();
|
const { listeners } = useTableNav();
|
||||||
|
|
||||||
|
const meta = useMemo(
|
||||||
|
() => ({
|
||||||
|
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
|
||||||
|
// check if value is the same
|
||||||
|
const event = data[rowIndex];
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip if there is no value change
|
||||||
|
const key = accessor as keyof OntimeEntry;
|
||||||
|
const previousValue = event[key];
|
||||||
|
if (previousValue === payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCustom) {
|
||||||
|
updateEntry({ id: event.id, custom: { [accessor]: payload } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEntry({ id: event.id, [accessor]: payload });
|
||||||
|
},
|
||||||
|
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => {
|
||||||
|
// the timer element already contains logic to avoid submitting a unchanged value
|
||||||
|
updateTimer(eventId, field, payload, true);
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
showDelayedTimes,
|
||||||
|
hideTableSeconds,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[data, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
|
||||||
|
useColumnManager(columns);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
@@ -45,52 +83,36 @@ export default function CuesheetTable({ data, columns, showModal }: CuesheetTabl
|
|||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onColumnSizingChange: setColumnSizing,
|
onColumnSizingChange: setColumnSizing,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
meta: {
|
meta,
|
||||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
|
|
||||||
// check if value is the same
|
|
||||||
const event = data[rowIndex];
|
|
||||||
|
|
||||||
if (!event || !isOntimeEvent(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// skip if there is no value change
|
|
||||||
const key = accessor as keyof OntimeEvent;
|
|
||||||
const previousValue = event[key];
|
|
||||||
if (previousValue === payload) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCustom) {
|
|
||||||
updateEntry({ id: event.id, custom: { [accessor]: payload } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateEntry({ id: event.id, [accessor]: payload });
|
|
||||||
},
|
|
||||||
handleUpdateTimer: (eventId: string, field: TimeField, payload) => {
|
|
||||||
// the timer element already contains logic to avoid submitting a unchanged value
|
|
||||||
updateTimer(eventId, field, payload, true);
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
showDelayedTimes,
|
|
||||||
hideTableSeconds,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const setAllVisible = useCallback(() => {
|
const setAllVisible = useCallback(() => {
|
||||||
table.toggleAllColumnsVisible(true);
|
table.toggleAllColumnsVisible(true);
|
||||||
}, []);
|
}, [table]);
|
||||||
|
|
||||||
const resetColumnResizing = useCallback(() => {
|
const resetColumnResizing = useCallback(() => {
|
||||||
setColumnSizing({});
|
setColumnSizing({});
|
||||||
}, []);
|
}, [setColumnSizing]);
|
||||||
|
|
||||||
const headerGroups = table.getHeaderGroups();
|
const headerGroups = table.getHeaderGroups();
|
||||||
const rowModel = table.getRowModel();
|
const rowModel = table.getRowModel();
|
||||||
const allLeafColumns = table.getAllLeafColumns();
|
const allLeafColumns = table.getAllLeafColumns();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To improve performance on resizing, we memoise the column sizes
|
||||||
|
* and pass them as CSS variables to the table container.
|
||||||
|
*/
|
||||||
|
const columnSizeVars = useMemo(() => {
|
||||||
|
const headers = table.getFlatHeaders();
|
||||||
|
const colSizes: { [key: string]: number } = {};
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
const header = headers[i]!;
|
||||||
|
colSizes[`--header-${header.id}-size`] = header.getSize();
|
||||||
|
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
|
||||||
|
}
|
||||||
|
return colSizes;
|
||||||
|
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CuesheetTableSettings
|
<CuesheetTableSettings
|
||||||
@@ -100,12 +122,24 @@ export default function CuesheetTable({ data, columns, showModal }: CuesheetTabl
|
|||||||
handleClearToggles={setAllVisible}
|
handleClearToggles={setAllVisible}
|
||||||
/>
|
/>
|
||||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||||
<table className={style.cuesheet} id='cuesheet' {...listeners}>
|
<table className={style.cuesheet} id='cuesheet' style={{ ...columnSizeVars }} {...listeners}>
|
||||||
<CuesheetHeader headerGroups={headerGroups} />
|
<CuesheetHeader headerGroups={headerGroups} />
|
||||||
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
{table.getState().columnSizingInfo.isResizingColumn ? (
|
||||||
|
<MemoisedBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
||||||
|
) : (
|
||||||
|
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
||||||
|
)}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<CuesheetTableMenu showModal={showModal} />
|
<CuesheetTableMenu />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While dragging, we avoid re-rendering the body by render
|
||||||
|
*/
|
||||||
|
const MemoisedBody = memo(
|
||||||
|
CuesheetBody,
|
||||||
|
(prev, next) => prev.table.options.data === next.table.options.data,
|
||||||
|
) as typeof CuesheetBody;
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
@import '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
|
.blockRow {
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 2px 0 0 0;
|
||||||
|
background-color: $gray-1300;
|
||||||
|
border-left: 4px solid var(--user-bg, $gray-500);
|
||||||
|
background: color-mix(in srgb, transparent 90%, var(--user-bg, $gray-500) 10%);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
line-height: 1em;
|
||||||
|
|
||||||
|
td {
|
||||||
|
min-height: 3.5rem;
|
||||||
|
padding-top: 0.75rem !important; // fighting styles from cuesheet-table
|
||||||
|
}
|
||||||
|
|
||||||
|
.indexColumn {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
outline: 1px solid $blue-500;
|
||||||
|
outline-offset: -1px;
|
||||||
|
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-28
@@ -1,47 +1,70 @@
|
|||||||
import { memo, useRef } from 'react';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
|
import { flexRender, Table } from '@tanstack/react-table';
|
||||||
|
import { EntryId, OntimeEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import { useCurrentBlockId } from '../../../../common/hooks/useSocket';
|
import { useCurrentBlockId } from '../../../../common/hooks/useSocket';
|
||||||
|
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||||
|
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||||
|
|
||||||
import style from '../CuesheetTable.module.scss';
|
import style from './BlockRow.module.scss';
|
||||||
|
|
||||||
interface BlockRowProps {
|
interface BlockRowProps {
|
||||||
|
blockId: EntryId;
|
||||||
|
colour: string;
|
||||||
hidePast: boolean;
|
hidePast: boolean;
|
||||||
title: string;
|
rowId: string;
|
||||||
columnCount: number;
|
rowIndex: number;
|
||||||
|
table: Table<OntimeEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlockRow({ hidePast, title, columnCount }: BlockRowProps) {
|
export default function BlockRow({ blockId, colour, hidePast, rowId, rowIndex, table }: BlockRowProps) {
|
||||||
const { currentBlockId } = useCurrentBlockId();
|
const { currentBlockId } = useCurrentBlockId();
|
||||||
const firstCellRef = useRef<null | HTMLTableCellElement>(null);
|
|
||||||
|
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||||
|
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
|
||||||
|
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||||
|
|
||||||
if (hidePast && !currentBlockId) {
|
if (hidePast && !currentBlockId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// guard the use case where user has hidden all columns
|
|
||||||
const fillColumns = Math.min(columnCount, 1);
|
|
||||||
|
|
||||||
const paddingRows = new Array(fillColumns).fill(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={style.blockRow}>
|
<tr className={style.blockRow} style={{ '--user-bg': colour }}>
|
||||||
<td tabIndex={-1} role='cell' ref={firstCellRef}>
|
{showActionMenu && (
|
||||||
{title}
|
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||||
</td>
|
<IconButton
|
||||||
{paddingRows.map((_value, index) => {
|
aria-label='Options'
|
||||||
return (
|
variant='subtle-white'
|
||||||
<td
|
size='small'
|
||||||
key={index}
|
onClick={(e) => {
|
||||||
tabIndex={-1}
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
role='cell'
|
const yPos = 8 + rect.y + rect.height / 2;
|
||||||
onFocus={() => {
|
openMenu({ x: rect.x, y: yPos }, blockId, rowIndex);
|
||||||
firstCellRef.current?.focus();
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
);
|
<IoEllipsisHorizontal />
|
||||||
})}
|
</IconButton>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{!hideIndexColumn && <td className={style.indexColumn} tabIndex={-1} role='cell' />}
|
||||||
|
{table
|
||||||
|
.getRow(rowId)
|
||||||
|
.getVisibleCells()
|
||||||
|
.map((cell) => {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{
|
||||||
|
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
||||||
|
}}
|
||||||
|
role='cell'
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(BlockRow);
|
|
||||||
|
|||||||
+64
-7
@@ -1,16 +1,19 @@
|
|||||||
import { MutableRefObject } from 'react';
|
import { MutableRefObject, useMemo } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { RowModel, Table } from '@tanstack/react-table';
|
import { RowModel, Table } from '@tanstack/react-table';
|
||||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeEntry } from 'ontime-types';
|
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types';
|
||||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { RUNDOWN } from '../../../../common/api/constants';
|
||||||
import { useSelectedEventId } from '../../../../common/hooks/useSocket';
|
import { useSelectedEventId } from '../../../../common/hooks/useSocket';
|
||||||
import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate';
|
import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate';
|
||||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
import { useCuesheetOptions } from '../../../cuesheet/cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../../../cuesheet/cuesheet.options';
|
||||||
|
|
||||||
import BlockRow from './BlockRow';
|
import BlockRow from './BlockRow';
|
||||||
import DelayRow from './DelayRow';
|
import DelayRow from './DelayRow';
|
||||||
import EventRow from './EventRow';
|
import EventRow from './EventRow';
|
||||||
|
import { useVisibleRowsStore } from './visibleRowsStore';
|
||||||
|
|
||||||
interface CuesheetBodyProps {
|
interface CuesheetBodyProps {
|
||||||
rowModel: RowModel<OntimeEntry>;
|
rowModel: RowModel<OntimeEntry>;
|
||||||
@@ -19,8 +22,29 @@ interface CuesheetBodyProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetBodyProps) {
|
export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetBodyProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { selectedEventId } = useSelectedEventId();
|
const { selectedEventId } = useSelectedEventId();
|
||||||
const { hideDelays, hidePast } = useCuesheetOptions();
|
const hidePast = usePersistedCuesheetOptions((state) => state.hidePast);
|
||||||
|
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
|
||||||
|
|
||||||
|
const { addVisibleRow, removeVisibleRow } = useVisibleRowsStore();
|
||||||
|
|
||||||
|
const observer = useMemo(
|
||||||
|
() =>
|
||||||
|
new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
addVisibleRow(entry.target.id);
|
||||||
|
} else {
|
||||||
|
removeVisibleRow(entry.target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '400px' },
|
||||||
|
),
|
||||||
|
[addVisibleRow, removeVisibleRow],
|
||||||
|
);
|
||||||
|
|
||||||
const getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns());
|
const getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns());
|
||||||
const getColumnHash = lazyEvaluate(() => {
|
const getColumnHash = lazyEvaluate(() => {
|
||||||
@@ -36,6 +60,7 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
|||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
// for the first event, it will be past if there is something selected
|
// for the first event, it will be past if there is something selected
|
||||||
let isPast = Boolean(selectedEventId);
|
let isPast = Boolean(selectedEventId);
|
||||||
|
let hadBlock = false;
|
||||||
return (
|
return (
|
||||||
<tbody>
|
<tbody>
|
||||||
{rowModel.rows.map((row, index) => {
|
{rowModel.rows.map((row, index) => {
|
||||||
@@ -47,8 +72,17 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeBlock(entry)) {
|
if (isOntimeBlock(entry)) {
|
||||||
const columnCount = getVisibleColumns().length;
|
return (
|
||||||
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
|
<BlockRow
|
||||||
|
key={key}
|
||||||
|
blockId={entry.id}
|
||||||
|
colour={entry.colour}
|
||||||
|
hidePast={isPast && hidePast}
|
||||||
|
rowId={row.id}
|
||||||
|
rowIndex={row.index}
|
||||||
|
table={table}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (isOntimeDelay(entry)) {
|
if (isOntimeDelay(entry)) {
|
||||||
if (isPast && hidePast) {
|
if (isPast && hidePast) {
|
||||||
@@ -59,13 +93,20 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <DelayRow key={key} duration={delayVal} />;
|
let parentBgColour: string | null = null;
|
||||||
|
if (entry.parent) {
|
||||||
|
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
const parentEntry = rundown?.entries[entry.parent];
|
||||||
|
parentBgColour = (parentEntry as OntimeBlock).colour ?? null;
|
||||||
|
}
|
||||||
|
return <DelayRow key={key} duration={delayVal} parentBgColour={parentBgColour} />;
|
||||||
}
|
}
|
||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
eventIndex++;
|
eventIndex++;
|
||||||
const isSelected = key === selectedEventId;
|
const isSelected = key === selectedEventId;
|
||||||
const columnHash = getColumnHash();
|
const columnHash = getColumnHash();
|
||||||
|
|
||||||
|
|
||||||
if (isPast && hidePast) {
|
if (isPast && hidePast) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -84,6 +125,19 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let parentBgColour: string | undefined;
|
||||||
|
let firstAfterBlock = false;
|
||||||
|
if (entry.parent) {
|
||||||
|
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
const parentEntry = rundown?.entries[entry.parent];
|
||||||
|
parentBgColour = (parentEntry as OntimeBlock).colour;
|
||||||
|
hadBlock = true;
|
||||||
|
} else if (hadBlock) {
|
||||||
|
firstAfterBlock = true;
|
||||||
|
hadBlock = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventRow
|
<EventRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
@@ -94,8 +148,11 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
|||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
selectedRef={isSelected ? selectedRef : undefined}
|
selectedRef={isSelected ? selectedRef : undefined}
|
||||||
rowBgColour={rowBgColour}
|
rowBgColour={rowBgColour}
|
||||||
|
parentBgColour={parentBgColour}
|
||||||
table={table}
|
table={table}
|
||||||
|
firstAfterBlock={firstAfterBlock}
|
||||||
columnHash={columnHash}
|
columnHash={columnHash}
|
||||||
|
observer={observer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -3,7 +3,7 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
|||||||
import { OntimeEntry } from 'ontime-types';
|
import { OntimeEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
import { useCuesheetOptions } from '../../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||||
|
|
||||||
import { SortableCell } from './SortableCell';
|
import { SortableCell } from './SortableCell';
|
||||||
|
|
||||||
@@ -14,7 +14,8 @@ interface CuesheetHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
|
export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
|
||||||
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
|
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||||
|
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<thead className={style.tableHeader}>
|
<thead className={style.tableHeader}>
|
||||||
@@ -31,7 +32,6 @@ export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
|
|||||||
)}
|
)}
|
||||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
const width = header.getSize();
|
|
||||||
// @ts-expect-error -- we inject this into react-table
|
// @ts-expect-error -- we inject this into react-table
|
||||||
const customBackground = header.column.columnDef?.meta?.colour;
|
const customBackground = header.column.columnDef?.meta?.colour;
|
||||||
|
|
||||||
@@ -42,7 +42,11 @@ export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
|
<SortableCell
|
||||||
|
key={header.column.columnDef.id}
|
||||||
|
header={header}
|
||||||
|
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||||
|
>
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</SortableCell>
|
</SortableCell>
|
||||||
);
|
);
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
@import '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
|
.delayRow {
|
||||||
|
width: calc(100vw - 2rem);
|
||||||
|
color: $ontime-delay-text;
|
||||||
|
border-left: 4px solid var(--user-bg);
|
||||||
|
|
||||||
|
td {
|
||||||
|
width: 100%;
|
||||||
|
padding-block: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:first-letter {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,23 @@ import { memo } from 'react';
|
|||||||
|
|
||||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||||
|
|
||||||
import style from '../CuesheetTable.module.scss';
|
import style from './DelayRow.module.scss';
|
||||||
|
|
||||||
interface DelayRowProps {
|
interface DelayRowProps {
|
||||||
duration: number;
|
duration: number;
|
||||||
|
parentBgColour: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DelayRow({ duration }: DelayRowProps) {
|
function DelayRow({ duration, parentBgColour }: DelayRowProps) {
|
||||||
const delayTime = millisToDelayString(duration, 'expanded');
|
const delayTime = millisToDelayString(duration, 'expanded');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={style.delayRow}>
|
<tr
|
||||||
|
className={style.delayRow}
|
||||||
|
style={{
|
||||||
|
'--user-bg': parentBgColour ?? 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<td tabIndex={0} role='cell'>
|
<td tabIndex={0} role='cell'>
|
||||||
{delayTime}
|
{delayTime}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+11
@@ -1,3 +1,12 @@
|
|||||||
|
.imageInput {
|
||||||
|
&::placeholder {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
&:hover::placeholder {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.imageCell {
|
.imageCell {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 2rem;
|
min-height: 2rem;
|
||||||
@@ -6,6 +15,8 @@
|
|||||||
.overlay {
|
.overlay {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 1rem;
|
||||||
background-color: $black-60;
|
background-color: $black-60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -1,5 +1,6 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
|
|
||||||
import style from './EditableImage.module.scss';
|
import style from './EditableImage.module.scss';
|
||||||
@@ -22,10 +23,17 @@ function EditableImage({ initialValue, updateValue }: EditableImageProps) {
|
|||||||
updateValue(newValue);
|
updateValue(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openInNewTab = () => {
|
||||||
|
if (initialValue) {
|
||||||
|
window.open(initialValue, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!initialValue) {
|
if (!initialValue) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
variant='ghosted'
|
variant='ghosted'
|
||||||
|
className={style.imageInput}
|
||||||
fluid
|
fluid
|
||||||
placeholder='Paste image URL'
|
placeholder='Paste image URL'
|
||||||
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
||||||
@@ -42,7 +50,12 @@ function EditableImage({ initialValue, updateValue }: EditableImageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={style.imageCell}>
|
<div className={style.imageCell}>
|
||||||
<div className={style.overlay}>
|
<div className={style.overlay}>
|
||||||
<button onClick={() => handleUpdate('')}>Delete</button>
|
<Button variant='subtle-white' onClick={openInNewTab}>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button variant='subtle-destructive' onClick={() => handleUpdate('')}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<img loading='lazy' src={initialValue} className={style.image} />
|
<img loading='lazy' src={initialValue} className={style.image} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
@import "../CuesheetTable.module.scss";
|
||||||
|
|
||||||
|
.eventRow {
|
||||||
|
vertical-align: top;
|
||||||
|
background: color-mix(in srgb, transparent 92%, var(--user-bg, $gray-500) 8%);
|
||||||
|
border-left: 4px solid var(--user-bg, $gray-500);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
outline: 1px solid $blue-500;
|
||||||
|
outline-offset: -1px;
|
||||||
|
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.firstAfterBlock {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.skip {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
rgba(255, 255, 255, 0.03),
|
||||||
|
rgba(255, 255, 255, 0.03) 10px,
|
||||||
|
rgba(255, 255, 255, 0.08) 10px,
|
||||||
|
rgba(255, 255, 255, 0.08) 20px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
background-color: $gray-1200;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-27
@@ -1,4 +1,4 @@
|
|||||||
import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react';
|
import { memo, MutableRefObject, useLayoutEffect, useRef } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
import { flexRender, Table } from '@tanstack/react-table';
|
import { flexRender, Table } from '@tanstack/react-table';
|
||||||
import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types';
|
import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types';
|
||||||
@@ -6,10 +6,12 @@ import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
|||||||
|
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
import { useCuesheetOptions } from '../../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||||
|
|
||||||
import style from '../CuesheetTable.module.scss';
|
import { useVisibleRowsStore } from './visibleRowsStore';
|
||||||
|
|
||||||
|
import style from './EventRow.module.scss';
|
||||||
|
|
||||||
interface EventRowProps {
|
interface EventRowProps {
|
||||||
rowId: string;
|
rowId: string;
|
||||||
@@ -21,9 +23,12 @@ interface EventRowProps {
|
|||||||
skip?: boolean;
|
skip?: boolean;
|
||||||
colour?: string;
|
colour?: string;
|
||||||
rowBgColour?: string;
|
rowBgColour?: string;
|
||||||
|
parentBgColour?: string;
|
||||||
table: Table<OntimeEntry>;
|
table: Table<OntimeEntry>;
|
||||||
/** hack to force re-rendering of the row when the column sizes change */
|
/** hack to force re-rendering of the row when the column sizes change */
|
||||||
columnHash: string;
|
columnHash: string;
|
||||||
|
observer: IntersectionObserver;
|
||||||
|
firstAfterBlock: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(EventRow, (prevProps, nextProps) => {
|
export default memo(EventRow, (prevProps, nextProps) => {
|
||||||
@@ -35,34 +40,36 @@ export default memo(EventRow, (prevProps, nextProps) => {
|
|||||||
prevProps.isPast === nextProps.isPast &&
|
prevProps.isPast === nextProps.isPast &&
|
||||||
prevProps.selectedRef === nextProps.selectedRef &&
|
prevProps.selectedRef === nextProps.selectedRef &&
|
||||||
prevProps.rowBgColour === nextProps.rowBgColour &&
|
prevProps.rowBgColour === nextProps.rowBgColour &&
|
||||||
|
prevProps.parentBgColour === nextProps.parentBgColour &&
|
||||||
prevProps.columnHash === nextProps.columnHash
|
prevProps.columnHash === nextProps.columnHash
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, rowBgColour, table }: EventRowProps) {
|
function EventRow({
|
||||||
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
|
rowId,
|
||||||
|
event,
|
||||||
|
eventIndex,
|
||||||
|
rowIndex,
|
||||||
|
isPast,
|
||||||
|
selectedRef,
|
||||||
|
rowBgColour,
|
||||||
|
parentBgColour,
|
||||||
|
table,
|
||||||
|
observer,
|
||||||
|
firstAfterBlock,
|
||||||
|
}: EventRowProps) {
|
||||||
|
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||||
|
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
|
||||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
|
const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId));
|
||||||
|
|
||||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||||
|
|
||||||
|
// store a reference of the row in the observer
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setIsVisible(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
root: null,
|
|
||||||
threshold: 0.01,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRefCurrent = ownRef.current;
|
const handleRefCurrent = ownRef.current;
|
||||||
if (selectedRef) {
|
if (handleRefCurrent) {
|
||||||
setIsVisible(true);
|
|
||||||
} else if (handleRefCurrent) {
|
|
||||||
observer.observe(handleRefCurrent);
|
observer.observe(handleRefCurrent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,22 +78,28 @@ function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, row
|
|||||||
observer.unobserve(handleRefCurrent);
|
observer.unobserve(handleRefCurrent);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [ownRef, selectedRef]);
|
}, [observer]);
|
||||||
|
|
||||||
const { color, backgroundColor } = getAccessibleColour(event.colour);
|
const { color, backgroundColor } = getAccessibleColour(event.colour);
|
||||||
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
|
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
|
||||||
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.6 });
|
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
className={cx([style.eventRow, event.skip ?? style.skip])}
|
id={rowId}
|
||||||
style={{ opacity: `${isPast ? '0.2' : '1'}` }}
|
className={cx([style.eventRow, event.skip && style.skip, firstAfterBlock && style.firstAfterBlock, Boolean(parentBgColour) && style.hasParent])}
|
||||||
|
style={{
|
||||||
|
opacity: `${isPast ? '0.2' : '1'}`,
|
||||||
|
'--user-bg': parentBgColour ?? 'transparent',
|
||||||
|
}}
|
||||||
ref={selectedRef ?? ownRef}
|
ref={selectedRef ?? ownRef}
|
||||||
>
|
>
|
||||||
{showActionMenu && (
|
{showActionMenu && (
|
||||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label='Options'
|
aria-label='Options'
|
||||||
|
variant='subtle-white'
|
||||||
|
size='small'
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const yPos = 8 + rect.y + rect.height / 2;
|
const yPos = 8 + rect.y + rect.height / 2;
|
||||||
@@ -105,12 +118,15 @@ function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, row
|
|||||||
{isVisible
|
{isVisible
|
||||||
? table
|
? table
|
||||||
.getRow(rowId)
|
.getRow(rowId)
|
||||||
?.getVisibleCells()
|
.getVisibleCells()
|
||||||
.map((cell) => {
|
.map((cell) => {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}
|
style={{
|
||||||
|
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
||||||
|
backgroundColor: rowBgColour,
|
||||||
|
}}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
role='cell'
|
role='cell'
|
||||||
>
|
>
|
||||||
|
|||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
$getRoot,
|
||||||
|
$getSelection,
|
||||||
|
EditorState,
|
||||||
|
LexicalEditor,
|
||||||
|
} from 'lexical';
|
||||||
|
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||||
|
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||||
|
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||||
|
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||||
|
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||||
|
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface LexicalEditorCellProps {
|
||||||
|
initialValue: string;
|
||||||
|
onSave: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorTheme = {
|
||||||
|
// Minimal theme, can be expanded later
|
||||||
|
ltr: 'ltr',
|
||||||
|
rtl: 'rtl',
|
||||||
|
placeholder: 'editor-placeholder',
|
||||||
|
paragraph: 'editor-paragraph',
|
||||||
|
};
|
||||||
|
|
||||||
|
function LexicalEditorCell({ initialValue, onSave }: LexicalEditorCellProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [currentValue, setCurrentValue] = useState(initialValue);
|
||||||
|
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
||||||
|
|
||||||
|
const initialConfig = {
|
||||||
|
namespace: 'LexicalEditorCell',
|
||||||
|
theme: editorTheme,
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error(error);
|
||||||
|
// Optionally, you could add more robust error handling here,
|
||||||
|
// like notifying the user or attempting to recover.
|
||||||
|
},
|
||||||
|
editorState: null, // No initial editor state when not editing
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCellClick = useCallback(() => {
|
||||||
|
setIsEditing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (editorState) {
|
||||||
|
editorState.read(() => {
|
||||||
|
const root = $getRoot();
|
||||||
|
const selection = $getSelection();
|
||||||
|
// For simplicity, we'll just get the text content.
|
||||||
|
// For actual rich text, you'd want to serialize to JSON.
|
||||||
|
const newTextValue = root.getTextContent();
|
||||||
|
onSave(newTextValue);
|
||||||
|
setCurrentValue(newTextValue); // Update local display value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [onSave, editorState]);
|
||||||
|
|
||||||
|
const onChange = (newEditorState: EditorState, editor: LexicalEditor) => {
|
||||||
|
setEditorState(newEditorState);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
return (
|
||||||
|
<div onClick={handleCellClick} style={{ cursor: 'pointer', minHeight: '20px', padding: '5px' }}>
|
||||||
|
{currentValue || <span style={{color: '#aaa'}}>Click to edit...</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LexicalComposer initialConfig={{...initialConfig, editorState: currentValue ? undefined : null}}>
|
||||||
|
<RichTextPlugin
|
||||||
|
contentEditable={<ContentEditable style={{minHeight: '150px', resize:'vertical', overflow:'auto', border:'1px solid #ccc', padding: '5px'}} />}
|
||||||
|
placeholder={<div style={{color: '#aaa', position: 'absolute', top: '30px', left: '10px'}}>Enter some text...</div>}
|
||||||
|
ErrorBoundary={LexicalErrorBoundary}
|
||||||
|
/>
|
||||||
|
<HistoryPlugin />
|
||||||
|
<OnChangePlugin onChange={onChange} />
|
||||||
|
{/* We need a way to trigger save, onBlur for the ContentEditable is one way */}
|
||||||
|
{/* Attaching onBlur directly to ContentEditable or its parent div within Lexical structure */}
|
||||||
|
<div onBlur={handleBlur} tabIndex={-1}> {/* Wrapper to capture blur */}
|
||||||
|
{/* This div is part of the Lexical structure and will be replaced by ContentEditable */}
|
||||||
|
</div>
|
||||||
|
</LexicalComposer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LexicalEditorCell;
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
.muted {
|
||||||
|
opacity: 0.4; // same as the time input with muted text
|
||||||
|
line-height: 2rem; // input height
|
||||||
|
}
|
||||||
|
|
||||||
|
.numeric {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
|
|
||||||
|
import style from './MutedText.module.scss';
|
||||||
|
|
||||||
|
interface MutedTextProps {
|
||||||
|
numeric?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MutedText({ numeric, children }: PropsWithChildren<MutedTextProps>) {
|
||||||
|
return <span className={cx([style.muted, numeric && style.numeric])}>{children}</span>;
|
||||||
|
}
|
||||||
+6
-5
@@ -4,15 +4,15 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import { Header } from '@tanstack/react-table';
|
import { Header } from '@tanstack/react-table';
|
||||||
import { OntimeEntry } from 'ontime-types';
|
import { OntimeEntry } from 'ontime-types';
|
||||||
|
|
||||||
import styles from '../CuesheetTable.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
interface SortableCellProps {
|
interface SortableCellProps {
|
||||||
header: Header<OntimeEntry, unknown>;
|
header: Header<OntimeEntry, unknown>;
|
||||||
style: CSSProperties;
|
injectedStyles: CSSProperties;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortableCell({ header, style, children }: SortableCellProps) {
|
export function SortableCell({ header, injectedStyles, children }: SortableCellProps) {
|
||||||
const { column, colSpan } = header;
|
const { column, colSpan } = header;
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
@@ -21,7 +21,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
|
|||||||
|
|
||||||
// build drag styles
|
// build drag styles
|
||||||
const dragStyle = {
|
const dragStyle = {
|
||||||
...style,
|
...injectedStyles,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
transform: CSS.Translate.toString(transform),
|
transform: CSS.Translate.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
@@ -34,10 +34,11 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
{...{
|
{...{
|
||||||
|
onDoubleClick: () => header.column.resetSize(),
|
||||||
onMouseDown: header.getResizeHandler(),
|
onMouseDown: header.getResizeHandler(),
|
||||||
onTouchStart: header.getResizeHandler(),
|
onTouchStart: header.getResizeHandler(),
|
||||||
}}
|
}}
|
||||||
className={styles.resizer}
|
className={style.resizer}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-1
@@ -7,10 +7,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.5px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&.delayed {
|
&.delayed {
|
||||||
color: $ontime-delay-text;
|
color: $ontime-delay-text;
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-36
@@ -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, OntimeEntry, OntimeEvent, TimeStrategy } from 'ontime-types';
|
import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||||
@@ -8,7 +8,9 @@ import { formatDuration, formatTime } from '../../../../common/utils/time';
|
|||||||
|
|
||||||
import DurationInput from './DurationInput';
|
import DurationInput from './DurationInput';
|
||||||
import EditableImage from './EditableImage';
|
import EditableImage from './EditableImage';
|
||||||
|
import LexicalEditorCell from './LexicalEditorCell';
|
||||||
import MultiLineCell from './MultiLineCell';
|
import MultiLineCell from './MultiLineCell';
|
||||||
|
import MutedText from './MutedText';
|
||||||
import SingleLineCell from './SingleLineCell';
|
import SingleLineCell from './SingleLineCell';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
|
|
||||||
@@ -17,24 +19,29 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeEntry, unknown>)
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { handleUpdateTimer } = table.options.meta;
|
|
||||||
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
|
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
|
||||||
|
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
|
||||||
|
|
||||||
|
const event = row.original;
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return <MutedText numeric>{formatTime(getValue() as number)}</MutedText>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { handleUpdateTimer } = table.options.meta;
|
||||||
|
|
||||||
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeStart', newValue);
|
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeStart', newValue);
|
||||||
|
|
||||||
const startTime = getValue() as number;
|
const startTime = getValue() as number;
|
||||||
const isStartLocked = !(row.original as OntimeEvent).linkStart;
|
const isStartLocked = !event.linkStart;
|
||||||
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
|
|
||||||
|
|
||||||
const displayTime = showDelayedTimes ? startTime + delayValue : startTime;
|
const displayTime = showDelayedTimes ? startTime + event.delay : startTime;
|
||||||
|
|
||||||
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
|
|
||||||
const formattedTime = formatTime(displayTime, formatOpts);
|
const formattedTime = formatTime(displayTime, formatOpts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={delayValue !== 0}>
|
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={event.delay !== 0}>
|
||||||
{formattedTime}
|
{formattedTime}
|
||||||
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(startTime)} />
|
<DelayIndicator delayValue={event.delay} tooltipPrefix={millisToString(startTime)} />
|
||||||
</TimeInput>
|
</TimeInput>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,24 +51,29 @@ function MakeEnd({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { handleUpdateTimer } = table.options.meta;
|
|
||||||
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
|
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
|
||||||
|
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
|
||||||
|
|
||||||
|
const event = row.original;
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return <MutedText numeric>{formatTime(getValue() as number, formatOpts)}</MutedText>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { handleUpdateTimer } = table.options.meta;
|
||||||
|
|
||||||
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeEnd', newValue);
|
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeEnd', newValue);
|
||||||
|
|
||||||
const endTime = getValue() as number;
|
const endTime = getValue() as number;
|
||||||
const isEndLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockEnd;
|
const isEndLocked = event.timeStrategy === TimeStrategy.LockEnd;
|
||||||
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
|
|
||||||
|
|
||||||
const displayTime = showDelayedTimes ? endTime + delayValue : endTime;
|
const displayTime = showDelayedTimes ? endTime + event.delay : endTime;
|
||||||
|
|
||||||
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
|
|
||||||
const formattedTime = formatTime(displayTime, formatOpts);
|
const formattedTime = formatTime(displayTime, formatOpts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={delayValue !== 0}>
|
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={event.delay !== 0}>
|
||||||
{formattedTime}
|
{formattedTime}
|
||||||
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(endTime)} />
|
<DelayIndicator delayValue={event.delay} tooltipPrefix={millisToString(endTime)} />
|
||||||
</TimeInput>
|
</TimeInput>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -71,13 +83,19 @@ function MakeDuration({ getValue, row, table }: CellContext<OntimeEntry, unknown
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { hideTableSeconds } = table.options.meta.options;
|
||||||
|
const event = row.original;
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return <MutedText numeric>{formatDuration(getValue() as number, hideTableSeconds)}</MutedText>;
|
||||||
|
}
|
||||||
|
|
||||||
const { handleUpdateTimer } = table.options.meta;
|
const { handleUpdateTimer } = table.options.meta;
|
||||||
|
|
||||||
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'duration', newValue);
|
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'duration', newValue);
|
||||||
|
|
||||||
const duration = getValue() as number;
|
const duration = getValue() as number;
|
||||||
const isDurationLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockDuration;
|
const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration;
|
||||||
const formattedDuration = formatDuration(duration, false);
|
const formattedDuration = formatDuration(duration, hideTableSeconds);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DurationInput initialValue={duration} onSubmit={update} lockedValue={isDurationLocked}>
|
<DurationInput initialValue={duration} onSubmit={update} lockedValue={isDurationLocked}>
|
||||||
@@ -91,17 +109,15 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
|
|||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
[column.id, row.index, table.options.meta],
|
||||||
[column.id, row.index],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const event = row.original;
|
// not all entries have all properties (eg blocks)
|
||||||
if (!isOntimeEvent(event)) {
|
const initialValue = row.original[column.id as keyof OntimeEntry];
|
||||||
|
if (initialValue === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialValue = event[column.id as keyof OntimeEntry] ?? '';
|
|
||||||
|
|
||||||
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,12 +126,11 @@ function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
|||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
[column.id, row.index, table.options.meta],
|
||||||
[column.id, row.index],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const event = row.original;
|
const event = row.original;
|
||||||
if (!isOntimeEvent(event)) {
|
if (isOntimeDelay(event)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,17 +143,15 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
|
|||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
[column.id, row.index, table.options.meta],
|
||||||
[column.id, row.index],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const event = row.original;
|
// not all entries have all properties (eg blocks)
|
||||||
if (!isOntimeEvent(event)) {
|
const initialValue = row.original[column.id as keyof OntimeEntry];
|
||||||
|
if (initialValue === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialValue = event[column.id as keyof OntimeEntry] ?? '';
|
|
||||||
|
|
||||||
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,20 +160,25 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknow
|
|||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
[column.id, row.index, table.options.meta],
|
||||||
[column.id, row.index],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const event = row.original;
|
const event = row.original;
|
||||||
if (!isOntimeEvent(event)) {
|
if (isOntimeDelay(event)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fields will not contain the field if there is no value set by the user
|
||||||
|
// event if there is no initial value, we still render the cell
|
||||||
const initialValue = event.custom[column.id] ?? '';
|
const initialValue = event.custom[column.id] ?? '';
|
||||||
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
return <LexicalEditorCell initialValue={initialValue} onSave={update} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeEntry>[] {
|
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeEntry>[] {
|
||||||
|
/**
|
||||||
|
* we cant use the createColumnHelper() because we have custom logic for rendering the cells
|
||||||
|
* This means that the display columns: index and action are added inline by the row components
|
||||||
|
*/
|
||||||
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
|
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
|
||||||
accessorKey: key,
|
accessorKey: key,
|
||||||
id: key,
|
id: key,
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface VisibleRowsStore {
|
||||||
|
visibleRows: Set<string>;
|
||||||
|
addVisibleRow: (id: string) => void;
|
||||||
|
removeVisibleRow: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVisibleRowsStore = create<VisibleRowsStore>((set) => ({
|
||||||
|
visibleRows: new Set(),
|
||||||
|
addVisibleRow: (id) => set((state) => ({ visibleRows: new Set(state.visibleRows).add(id) })),
|
||||||
|
removeVisibleRow: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const newSet = new Set(state.visibleRows);
|
||||||
|
newSet.delete(id);
|
||||||
|
return { visibleRows: newSet };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
+53
-8
@@ -1,17 +1,38 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Menu, MenuButton, Portal } from '@chakra-ui/react';
|
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
|
||||||
|
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList, Portal } from '@chakra-ui/react';
|
||||||
|
import { isOntimeEvent, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
||||||
|
import { cloneEvent } from '../../../../common/utils/clone';
|
||||||
|
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
|
||||||
|
|
||||||
import CuesheetTableMenuActionsProps from './CuesheetTableMenuActions';
|
|
||||||
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
|
||||||
|
|
||||||
interface CuesheetTableMenuProps {
|
|
||||||
showModal: (eventId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(CuesheetTableMenu);
|
export default memo(CuesheetTableMenu);
|
||||||
|
|
||||||
function CuesheetTableMenu({ showModal }: CuesheetTableMenuProps) {
|
function CuesheetTableMenu() {
|
||||||
const { isOpen, eventId, entryIndex, position, closeMenu } = useCuesheetTableMenu();
|
const { isOpen, eventId, entryIndex, position, closeMenu } = useCuesheetTableMenu();
|
||||||
|
const { addEntry, getEntryById, move, deleteEntry } = useEntryActions();
|
||||||
|
const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
|
||||||
|
|
||||||
|
const handleCloneEvent = () => {
|
||||||
|
if (!eventId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEvent = getEntryById(eventId);
|
||||||
|
if (!currentEvent || !isOntimeEvent(currentEvent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEvent = cloneEvent(currentEvent);
|
||||||
|
try {
|
||||||
|
addEntry(newEvent, { after: eventId });
|
||||||
|
} catch (_error) {
|
||||||
|
// we do not handle errors here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
@@ -26,7 +47,31 @@ function CuesheetTableMenu({ showModal }: CuesheetTableMenuProps) {
|
|||||||
w={1}
|
w={1}
|
||||||
h={1}
|
h={1}
|
||||||
/>
|
/>
|
||||||
<CuesheetTableMenuActionsProps eventId={eventId} entryIndex={entryIndex} showModal={showModal} />
|
<MenuList>
|
||||||
|
<MenuItem icon={<IoOptions />} onClick={() => showModal(eventId)}>
|
||||||
|
Edit ...
|
||||||
|
</MenuItem>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}>
|
||||||
|
Add event above
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}>
|
||||||
|
Add event below
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem icon={<IoDuplicateOutline />} onClick={handleCloneEvent}>
|
||||||
|
Clone event
|
||||||
|
</MenuItem>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItem isDisabled={entryIndex < 1} icon={<IoArrowUp />} onClick={() => move(eventId, 'up')}>
|
||||||
|
Move up
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem icon={<IoArrowDown />} onClick={() => move(eventId, 'down')}>
|
||||||
|
Move down
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([eventId])}>
|
||||||
|
Delete
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|||||||
-58
@@ -1,58 +0,0 @@
|
|||||||
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
|
|
||||||
import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
|
|
||||||
import { isOntimeEvent, SupportedEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
|
||||||
import { cloneEvent } from '../../../../common/utils/clone';
|
|
||||||
|
|
||||||
interface CuesheetTableMenuActionsProps {
|
|
||||||
eventId: string;
|
|
||||||
entryIndex: number;
|
|
||||||
showModal: (entryId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CuesheetTableMenuActions({ eventId, entryIndex, showModal }: CuesheetTableMenuActionsProps) {
|
|
||||||
const { addEntry, getEntryById, move, deleteEntry } = useEntryActions();
|
|
||||||
|
|
||||||
const handleCloneEvent = () => {
|
|
||||||
const currentEvent = getEntryById(eventId);
|
|
||||||
if (!currentEvent || !isOntimeEvent(currentEvent)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEvent = cloneEvent(currentEvent);
|
|
||||||
try {
|
|
||||||
addEntry(newEvent, { after: eventId });
|
|
||||||
} catch (_error) {
|
|
||||||
// we do not handle errors here
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MenuList>
|
|
||||||
<MenuItem icon={<IoOptions />} onClick={() => showModal(eventId)}>
|
|
||||||
Edit ...
|
|
||||||
</MenuItem>
|
|
||||||
<MenuDivider />
|
|
||||||
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}>
|
|
||||||
Add event above
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}>
|
|
||||||
Add event below
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem icon={<IoDuplicateOutline />} onClick={handleCloneEvent}>
|
|
||||||
Clone event
|
|
||||||
</MenuItem>
|
|
||||||
<MenuDivider />
|
|
||||||
<MenuItem isDisabled={entryIndex < 1} icon={<IoArrowUp />} onClick={() => move(eventId, 'up')}>
|
|
||||||
Move up
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem icon={<IoArrowDown />} onClick={() => move(eventId, 'down')}>
|
|
||||||
Move down
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([eventId])}>
|
|
||||||
Delete
|
|
||||||
</MenuItem>
|
|
||||||
</MenuList>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+19
-6
@@ -1,13 +1,13 @@
|
|||||||
.tableSettings {
|
.tableSettings {
|
||||||
|
margin-top: 1rem;
|
||||||
grid-area: settings;
|
grid-area: settings;
|
||||||
padding-inline: 0.5rem;
|
background-color: $gray-1250;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 5rem;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
@media (max-width: $small-screen) {
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
@@ -27,3 +27,16 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
+135
-15
@@ -1,10 +1,15 @@
|
|||||||
import { memo, ReactNode } from 'react';
|
import { memo, ReactNode } from 'react';
|
||||||
import { Column } from '@tanstack/react-table';
|
import { IoChevronDown, IoLocate, IoOptions, IoSettingsOutline } from 'react-icons/io5';
|
||||||
|
import { Popover } from '@base-ui-components/react/popover';
|
||||||
|
import type { Column } from '@tanstack/react-table';
|
||||||
import { OntimeEntry } from 'ontime-types';
|
import { OntimeEntry } from 'ontime-types';
|
||||||
|
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||||
|
import RotatedLink from '../../../../common/components/icons/RotatedLink';
|
||||||
|
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||||
|
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||||
|
|
||||||
import style from './CuesheetTableSettings.module.scss';
|
import style from './CuesheetTableSettings.module.scss';
|
||||||
|
|
||||||
@@ -15,6 +20,7 @@ interface CuesheetTableSettingsProps {
|
|||||||
handleClearToggles: () => void;
|
handleClearToggles: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(CuesheetTableSettings);
|
||||||
function CuesheetTableSettings({
|
function CuesheetTableSettings({
|
||||||
columns,
|
columns,
|
||||||
handleResetResizing,
|
handleResetResizing,
|
||||||
@@ -23,9 +29,126 @@ function CuesheetTableSettings({
|
|||||||
}: CuesheetTableSettingsProps) {
|
}: CuesheetTableSettingsProps) {
|
||||||
return (
|
return (
|
||||||
<div className={style.tableSettings}>
|
<div className={style.tableSettings}>
|
||||||
<div>
|
<div className={style.inline}>
|
||||||
<Editor.Label className={style.sectionTitle}>Toggle column visibility</Editor.Label>
|
<ViewSettings />
|
||||||
<div className={style.row}>
|
<ColumnSettings
|
||||||
|
columns={columns}
|
||||||
|
handleResetResizing={handleResetResizing}
|
||||||
|
handleResetReordering={handleResetReordering}
|
||||||
|
handleClearToggles={handleClearToggles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.inline}>
|
||||||
|
<ViewSettingsFollowButton />
|
||||||
|
|
||||||
|
<Editor.Separator orientation='vertical' />
|
||||||
|
|
||||||
|
<Button variant='subtle'>
|
||||||
|
<RotatedLink />
|
||||||
|
Share...
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewSettingsFollowButton() {
|
||||||
|
const followPlayback = usePersistedCuesheetOptions((state) => state.followPlayback);
|
||||||
|
const toggle = usePersistedCuesheetOptions((state) => state.toggleOption);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant={followPlayback ? 'primary' : 'subtle'} onClick={() => toggle('followPlayback')}>
|
||||||
|
<IoLocate />
|
||||||
|
{followPlayback ? 'Following playback' : 'Follow playback'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewSettings() {
|
||||||
|
const options = usePersistedCuesheetOptions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger
|
||||||
|
render={
|
||||||
|
<Button variant='ghosted'>
|
||||||
|
<IoSettingsOutline /> Settings
|
||||||
|
<IoChevronDown />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PopoverContents align='start' className={style.column}>
|
||||||
|
<Editor.Label className={style.sectionTitle}>Element visibility</Editor.Label>
|
||||||
|
<Editor.Label className={style.option}>
|
||||||
|
<Checkbox
|
||||||
|
defaultChecked={options.showActionMenu}
|
||||||
|
onCheckedChange={(checked) => options.setOption('showActionMenu', checked)}
|
||||||
|
/>
|
||||||
|
Show action menu
|
||||||
|
</Editor.Label>
|
||||||
|
<Editor.Label className={style.option}>
|
||||||
|
<Checkbox
|
||||||
|
defaultChecked={options.hideTableSeconds}
|
||||||
|
onCheckedChange={(checked) => options.setOption('hideTableSeconds', checked)}
|
||||||
|
/>
|
||||||
|
Hide seconds in table
|
||||||
|
</Editor.Label>
|
||||||
|
<Editor.Label className={style.option}>
|
||||||
|
<Checkbox
|
||||||
|
defaultChecked={options.hidePast}
|
||||||
|
onCheckedChange={(checked) => options.setOption('hidePast', checked)}
|
||||||
|
/>
|
||||||
|
Hide past events
|
||||||
|
</Editor.Label>
|
||||||
|
<Editor.Label className={style.option}>
|
||||||
|
<Checkbox
|
||||||
|
defaultChecked={options.hideIndexColumn}
|
||||||
|
onCheckedChange={(checked) => options.setOption('hideIndexColumn', checked)}
|
||||||
|
/>
|
||||||
|
Hide index column
|
||||||
|
</Editor.Label>
|
||||||
|
|
||||||
|
<Editor.Label className={style.sectionTitle}>Table Behaviour</Editor.Label>
|
||||||
|
<Editor.Label className={style.option}>
|
||||||
|
<Checkbox
|
||||||
|
defaultChecked={options.showDelayedTimes}
|
||||||
|
onCheckedChange={(checked) => options.setOption('showDelayedTimes', checked)}
|
||||||
|
/>
|
||||||
|
Show delayed times
|
||||||
|
</Editor.Label>
|
||||||
|
<Editor.Label className={style.option}>
|
||||||
|
<Checkbox
|
||||||
|
defaultChecked={options.hideDelays}
|
||||||
|
onCheckedChange={(checked) => options.setOption('hideDelays', checked)}
|
||||||
|
/>
|
||||||
|
Hide delay entries
|
||||||
|
</Editor.Label>
|
||||||
|
</PopoverContents>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColumnSettings({
|
||||||
|
columns,
|
||||||
|
handleResetResizing,
|
||||||
|
handleResetReordering,
|
||||||
|
handleClearToggles,
|
||||||
|
}: CuesheetTableSettingsProps) {
|
||||||
|
return (
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger
|
||||||
|
render={
|
||||||
|
<Button variant='ghosted'>
|
||||||
|
<IoOptions /> View
|
||||||
|
<IoChevronDown />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContents align='start' className={style.inline}>
|
||||||
|
<div className={style.column}>
|
||||||
|
<Editor.Label className={style.sectionTitle}>Column visibility</Editor.Label>
|
||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const columnHeader = column.columnDef.header;
|
const columnHeader = column.columnDef.header;
|
||||||
const visible = column.getIsVisible();
|
const visible = column.getIsVisible();
|
||||||
@@ -37,23 +160,20 @@ function CuesheetTableSettings({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Editor.Separator orientation='vertical' />
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<Editor.Label className={style.sectionTitle}>Reset Options</Editor.Label>
|
<Editor.Label className={style.sectionTitle}>Reset Options</Editor.Label>
|
||||||
<div className={style.row}>
|
<Button size='small' fluid onClick={handleClearToggles}>
|
||||||
<Button size='small' variant='subtle' onClick={handleClearToggles}>
|
|
||||||
Show All
|
Show All
|
||||||
</Button>
|
</Button>
|
||||||
<Button size='small' variant='subtle' onClick={handleResetResizing}>
|
<Button size='small' fluid onClick={handleResetResizing}>
|
||||||
Reset Resizing
|
Reset Resizing
|
||||||
</Button>
|
</Button>
|
||||||
<Button size='small' variant='subtle' onClick={handleResetReordering}>
|
<Button size='small' fluid onClick={handleResetReordering}>
|
||||||
Reset Reordering
|
Reset Reordering
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PopoverContents>
|
||||||
</div>
|
</Popover.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(CuesheetTableSettings);
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function useColumnManager(columns: ColumnDef<OntimeEntry>[]) {
|
|||||||
});
|
});
|
||||||
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
|
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
|
||||||
|
|
||||||
// if the columns change, we update the dataset
|
// if the columns order changes, we update the dataset
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let shouldReplace = false;
|
let shouldReplace = false;
|
||||||
const newColumns: string[] = [];
|
const newColumns: string[] = [];
|
||||||
|
|||||||
@@ -1,110 +1,46 @@
|
|||||||
import { useMemo } from 'react';
|
import { create } from 'zustand';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
type OptionValues = {
|
||||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
|
||||||
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In the specific case of the cuesheet options
|
|
||||||
* we save the user preferences in the local storage
|
|
||||||
*/
|
|
||||||
export const cuesheetOptions: ViewOption[] = [
|
|
||||||
{
|
|
||||||
title: OptionTitle.ElementVisibility,
|
|
||||||
collapsible: true,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
id: 'showActionMenu',
|
|
||||||
title: 'Show action menu',
|
|
||||||
description: 'Whether to show the action menu for every row in the table',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideTableSeconds',
|
|
||||||
title: 'Hide seconds in table',
|
|
||||||
description: 'Whether to hide seconds in the time fields displayed in the table',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'followSelected',
|
|
||||||
title: 'Follow selected event',
|
|
||||||
description: 'Whether the view should automatically scroll to the selected event',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hidePast',
|
|
||||||
title: 'Hide Past Events',
|
|
||||||
description: 'Whether to hide events that have passed',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideIndexColumn',
|
|
||||||
title: 'Hide index column',
|
|
||||||
description: 'Whether the hide the event indexes in the table',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: OptionTitle.BehaviourOptions,
|
|
||||||
collapsible: true,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
id: 'showDelayedTimes',
|
|
||||||
title: 'Show delayed times',
|
|
||||||
description: 'Whether the time fields should include delays',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideDelays',
|
|
||||||
title: 'Hide delays',
|
|
||||||
description: 'Whether to hide the rows containing scheduled delays',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
type CuesheetOptions = {
|
|
||||||
showActionMenu: boolean;
|
showActionMenu: boolean;
|
||||||
hideTableSeconds: boolean;
|
hideTableSeconds: boolean;
|
||||||
followSelected: boolean;
|
followPlayback: boolean;
|
||||||
hidePast: boolean;
|
hidePast: boolean;
|
||||||
hideIndexColumn: boolean;
|
hideIndexColumn: boolean;
|
||||||
showDelayedTimes: boolean;
|
showDelayedTimes: boolean;
|
||||||
hideDelays: boolean;
|
hideDelays: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const defaultOptions: OptionValues = {
|
||||||
* Utility extract the view options from URL Params
|
showActionMenu: false,
|
||||||
* the names and fallbacks are manually matched with cuesheetOptions
|
hideTableSeconds: false,
|
||||||
*/
|
followPlayback: false,
|
||||||
function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
|
hidePast: false,
|
||||||
// we manually make an object that matches the key above
|
hideIndexColumn: false,
|
||||||
return {
|
showDelayedTimes: false,
|
||||||
showActionMenu: isStringBoolean(searchParams.get('showActionMenu')),
|
hideDelays: false,
|
||||||
hideTableSeconds: isStringBoolean(searchParams.get('hideTableSeconds')),
|
};
|
||||||
followSelected: isStringBoolean(searchParams.get('followSelected')),
|
|
||||||
hidePast: isStringBoolean(searchParams.get('hidePast')),
|
export type CuesheetOptionKeys = keyof OptionValues;
|
||||||
hideIndexColumn: isStringBoolean(searchParams.get('hideIndexColumn')),
|
|
||||||
showDelayedTimes: isStringBoolean(searchParams.get('showDelayedTimes')),
|
export interface CuesheetOptions extends OptionValues {
|
||||||
hideDelays: isStringBoolean(searchParams.get('hideDelays')),
|
setOption: <K extends CuesheetOptionKeys>(key: K, value: OptionValues[K]) => void;
|
||||||
};
|
toggleOption: (key: CuesheetOptionKeys) => void;
|
||||||
|
resetOptions: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const usePersistedCuesheetOptions = create<CuesheetOptions>()(
|
||||||
* Hook exposes the cuesheet view options
|
persist(
|
||||||
*/
|
(set) => {
|
||||||
export function useCuesheetOptions(): CuesheetOptions {
|
return {
|
||||||
const [searchParams] = useSearchParams();
|
...defaultOptions,
|
||||||
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
setOption: (key, value) => set((state) => ({ ...state, [key]: value })),
|
||||||
return options;
|
toggleOption: (key) => set((state) => ({ ...state, [key]: !state[key] })),
|
||||||
}
|
resetOptions: () => set(defaultOptions),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cuesheet-options',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface LegacyData extends Partial<DatabaseModel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseAutomationSettings(data: LegacyData, emitError?: ErrorEmitter): AutomationSettings {
|
export function parseAutomationSettings(data: LegacyData, emitError?: ErrorEmitter): AutomationSettings {
|
||||||
|
// TODO(v4): move to migration script
|
||||||
/**
|
/**
|
||||||
* Leaving a path for migrating users to the new automations
|
* Leaving a path for migrating users to the new automations
|
||||||
* This should be removed after a few releases
|
* This should be removed after a few releases
|
||||||
|
|||||||
@@ -585,8 +585,8 @@ describe('processRundown()', () => {
|
|||||||
'1': {
|
'1': {
|
||||||
type: SupportedEntry.Block,
|
type: SupportedEntry.Block,
|
||||||
entries: ['100', '200', '300'],
|
entries: ['100', '200', '300'],
|
||||||
startTime: 100,
|
timeStart: 100,
|
||||||
endTime: 400,
|
timeEnd: 400,
|
||||||
duration: 300,
|
duration: 300,
|
||||||
isFirstLinked: false,
|
isFirstLinked: false,
|
||||||
},
|
},
|
||||||
@@ -625,8 +625,8 @@ describe('processRundown()', () => {
|
|||||||
'1': {
|
'1': {
|
||||||
type: SupportedEntry.Block,
|
type: SupportedEntry.Block,
|
||||||
entries: ['101', '102', '103'],
|
entries: ['101', '102', '103'],
|
||||||
startTime: 100,
|
timeStart: 100,
|
||||||
endTime: 400,
|
timeEnd: 400,
|
||||||
duration: 300,
|
duration: 300,
|
||||||
isFirstLinked: false,
|
isFirstLinked: false,
|
||||||
},
|
},
|
||||||
@@ -636,8 +636,8 @@ describe('processRundown()', () => {
|
|||||||
'2': {
|
'2': {
|
||||||
type: SupportedEntry.Block,
|
type: SupportedEntry.Block,
|
||||||
entries: ['201', '202', '203'],
|
entries: ['201', '202', '203'],
|
||||||
startTime: 500,
|
timeStart: 500,
|
||||||
endTime: 800,
|
timeEnd: 800,
|
||||||
duration: 300,
|
duration: 300,
|
||||||
isFirstLinked: false,
|
isFirstLinked: false,
|
||||||
},
|
},
|
||||||
@@ -647,8 +647,8 @@ describe('processRundown()', () => {
|
|||||||
'3': {
|
'3': {
|
||||||
type: SupportedEntry.Block,
|
type: SupportedEntry.Block,
|
||||||
entries: ['301', '302', '303'],
|
entries: ['301', '302', '303'],
|
||||||
startTime: 900,
|
timeStart: 900,
|
||||||
endTime: 1200,
|
timeEnd: 1200,
|
||||||
duration: 300,
|
duration: 300,
|
||||||
isFirstLinked: false,
|
isFirstLinked: false,
|
||||||
},
|
},
|
||||||
@@ -699,7 +699,7 @@ describe('rundownMutation.add()', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, null, '1');
|
rundownMutation.add(rundown, mockEvent, null, rundown.entries['1'] as OntimeBlock);
|
||||||
|
|
||||||
expect(rundown.order).toStrictEqual(['1']);
|
expect(rundown.order).toStrictEqual(['1']);
|
||||||
expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']);
|
expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']);
|
||||||
@@ -717,7 +717,7 @@ describe('rundownMutation.add()', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, '1a', '1');
|
rundownMutation.add(rundown, mockEvent, '1a', rundown.entries['1'] as OntimeBlock);
|
||||||
|
|
||||||
expect(rundown.order).toStrictEqual(['1']);
|
expect(rundown.order).toStrictEqual(['1']);
|
||||||
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
|
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
|
||||||
|
|||||||
@@ -213,6 +213,64 @@ describe('parseRundown()', () => {
|
|||||||
expect((parsedRundown.entries['2'] as OntimeEvent).custom).toStrictEqual({ sound: 'loud' });
|
expect((parsedRundown.entries['2'] as OntimeEvent).custom).toStrictEqual({ sound: 'loud' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes empty custom fields', () => {
|
||||||
|
const rundown = {
|
||||||
|
id: 'test',
|
||||||
|
title: '',
|
||||||
|
order: ['1', '2'],
|
||||||
|
flatOrder: ['1', '2', '21'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({ id: '1', custom: { lighting: 'yes' } }),
|
||||||
|
'2': makeOntimeBlock({ id: '2', entries: ['21'], custom: { lighting: '' } }),
|
||||||
|
'21': makeOntimeEvent({ id: '21', custom: { lighting: '' } }),
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
|
const customFields: CustomFields = {
|
||||||
|
lighting: {
|
||||||
|
type: 'string',
|
||||||
|
colour: 'red',
|
||||||
|
label: 'lighting',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsedRundown = parseRundown(rundown, customFields);
|
||||||
|
expect((parsedRundown.entries['1'] as OntimeEvent).custom).toStrictEqual({ lighting: 'yes' });
|
||||||
|
expect((parsedRundown.entries['2'] as OntimeBlock).custom).not.toHaveProperty('lighting');
|
||||||
|
expect((parsedRundown.entries['21'] as OntimeEvent).custom).not.toHaveProperty('lighting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses data in blocks', () => {
|
||||||
|
const rundown = {
|
||||||
|
id: 'test',
|
||||||
|
title: '',
|
||||||
|
order: ['block'],
|
||||||
|
flatOrder: ['block'],
|
||||||
|
isNextDay: false,
|
||||||
|
entries: {
|
||||||
|
block: makeOntimeBlock({
|
||||||
|
id: 'block',
|
||||||
|
title: 'block-title',
|
||||||
|
note: 'block-note',
|
||||||
|
colour: 'red',
|
||||||
|
entries: ['1', '2'],
|
||||||
|
}),
|
||||||
|
'1': makeOntimeEvent({ id: '1' }),
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
|
const parsedRundown = parseRundown(rundown, {});
|
||||||
|
expect(parsedRundown.order.length).toEqual(1);
|
||||||
|
expect(parsedRundown.entries.block).toMatchObject({
|
||||||
|
title: 'block-title',
|
||||||
|
note: 'block-note',
|
||||||
|
colour: 'red',
|
||||||
|
entries: ['1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('parses events nested in blocks', () => {
|
it('parses events nested in blocks', () => {
|
||||||
const rundown = {
|
const rundown = {
|
||||||
id: 'test',
|
id: 'test',
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
|
import { TimeStrategy, EndAction, TimerType, OntimeEvent, OntimeBlock } from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
import { assertType } from 'vitest';
|
import { assertType } from 'vitest';
|
||||||
|
|
||||||
import { calculateDayOffset, createEvent, deleteById, doesInvalidateMetadata, getInsertAfterId, hasChanges } from '../rundown.utils.js';
|
import {
|
||||||
import { makeRundown } from '../__mocks__/rundown.mocks.js';
|
calculateDayOffset,
|
||||||
|
createEvent,
|
||||||
|
deleteById,
|
||||||
|
doesInvalidateMetadata,
|
||||||
|
getInsertAfterId,
|
||||||
|
hasChanges,
|
||||||
|
} from '../rundown.utils.js';
|
||||||
|
import { makeOntimeBlock, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
describe('test event validator', () => {
|
describe('test event validator', () => {
|
||||||
it('validates a good object', () => {
|
it('validates a good object', () => {
|
||||||
@@ -217,22 +224,39 @@ describe('calculateDayOffset()', () => {
|
|||||||
|
|
||||||
describe('getInsertAfterId()', () => {
|
describe('getInsertAfterId()', () => {
|
||||||
const rundown = makeRundown({
|
const rundown = makeRundown({
|
||||||
flatOrder: ['a', 'b', 'c', 'd'],
|
entries: {
|
||||||
|
'1': makeOntimeEvent({ id: '1', parent: null }),
|
||||||
|
'2': makeOntimeEvent({ id: '2', parent: null }),
|
||||||
|
block: makeOntimeBlock({ id: 'block', entries: ['31', '32'] }),
|
||||||
|
'31': makeOntimeEvent({ id: '31', parent: 'block' }),
|
||||||
|
'32': makeOntimeEvent({ id: '32', parent: 'block' }),
|
||||||
|
'4': makeOntimeEvent({ id: '31', parent: null }),
|
||||||
|
},
|
||||||
|
order: ['1', '2', 'block', '4'],
|
||||||
|
flatOrder: ['1', '2', 'block', '31', '32', '4'],
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns afterId if provided', () => {
|
it('returns afterId if provided', () => {
|
||||||
expect(getInsertAfterId(rundown, 'b')).toBe('b');
|
expect(getInsertAfterId(rundown, null, 'b')).toBe('b');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the previous id before beforeId if provided', () => {
|
it('returns null if neither afterId nor beforeId is provided', () => {
|
||||||
expect(getInsertAfterId(rundown, undefined, 'c')).toBe('b');
|
expect(getInsertAfterId(rundown, null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined if neither afterId nor beforeId is provided', () => {
|
it('returns null if beforeId is not found', () => {
|
||||||
expect(getInsertAfterId(rundown)).toBeNull();
|
expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull();
|
||||||
|
expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined if beforeId is not found', () => {
|
it('returns the previous id of an entry in the rundown', () => {
|
||||||
expect(getInsertAfterId(rundown, undefined, 'z')).toBeNull();
|
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1');
|
||||||
|
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('block');
|
||||||
|
expect(getInsertAfterId(rundown, null, undefined, 'block')).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the previous id of an event in a block', () => {
|
||||||
|
expect(getInsertAfterId(rundown, rundown.entries.block as OntimeBlock, undefined, '31')).toBeNull();
|
||||||
|
expect(getInsertAfterId(rundown, rundown.entries.block as OntimeBlock, undefined, '32')).toBe('31');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -189,18 +189,17 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
|||||||
* - 2a. add entry to the rundown, after a given entry
|
* - 2a. add entry to the rundown, after a given entry
|
||||||
* - 2b. add entry to the rundown, at the beginning
|
* - 2b. add entry to the rundown, at the beginning
|
||||||
*/
|
*/
|
||||||
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parentId: EntryId | null): OntimeEntry {
|
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeBlock | null): OntimeEntry {
|
||||||
if (parentId) {
|
if (parent) {
|
||||||
// 1. inserting an entry inside a block
|
// 1. inserting an entry inside a block
|
||||||
const parentBlock = rundown.entries[parentId] as OntimeBlock;
|
|
||||||
if (afterId) {
|
if (afterId) {
|
||||||
const atEventsIndex = parentBlock.entries.indexOf(afterId) + 1;
|
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
|
||||||
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
||||||
parentBlock.entries = insertAtIndex(atEventsIndex, entry.id, parentBlock.entries);
|
parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries);
|
||||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||||
} else {
|
} else {
|
||||||
parentBlock.entries = insertAtIndex(0, entry.id, parentBlock.entries);
|
parent.entries = insertAtIndex(0, entry.id, parent.entries);
|
||||||
const atFlatIndex = rundown.flatOrder.indexOf(parentId) + 1;
|
const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1;
|
||||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -469,7 +468,8 @@ function clone(rundown: Rundown, entry: OntimeEntry): OntimeEntry {
|
|||||||
|
|
||||||
return newBlock;
|
return newBlock;
|
||||||
} else {
|
} else {
|
||||||
return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, entry.parent);
|
const parent: OntimeBlock | null = entry.parent ? (rundown.entries[entry.parent] as OntimeBlock) : null;
|
||||||
|
return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,8 +747,8 @@ export function processRundown(
|
|||||||
|
|
||||||
// update block metadata
|
// update block metadata
|
||||||
processedEntry.duration = totalBlockDuration;
|
processedEntry.duration = totalBlockDuration;
|
||||||
processedEntry.startTime = blockStartTime;
|
processedEntry.timeStart = blockStartTime;
|
||||||
processedEntry.endTime = blockEndTime;
|
processedEntry.timeEnd = blockEndTime;
|
||||||
processedEntry.isFirstLinked = isFirstLinked;
|
processedEntry.isFirstLinked = isFirstLinked;
|
||||||
processedEntry.entries = blockEvents;
|
processedEntry.entries = blockEvents;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import {
|
|||||||
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
||||||
|
|
||||||
import { defaultRundown } from '../../models/dataModel.js';
|
import { defaultRundown } from '../../models/dataModel.js';
|
||||||
import { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js';
|
import { delay as delayDef } from '../../models/eventsDefinition.js';
|
||||||
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||||
|
|
||||||
import { calculateDayOffset, createEvent } from './rundown.utils.js';
|
import { calculateDayOffset, cleanupCustomFields, createBlock, createEvent } from './rundown.utils.js';
|
||||||
import { RundownMetadata } from './rundown.types.js';
|
import { RundownMetadata } from './rundown.types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,14 +104,7 @@ export function parseRundown(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// for every field in custom, check that a key exists in customfields
|
cleanupCustomFields(newEvent.custom, parsedCustomFields);
|
||||||
for (const field in newEvent.custom) {
|
|
||||||
if (!Object.hasOwn(parsedCustomFields, field)) {
|
|
||||||
emitError?.(`Custom field ${field} not found`);
|
|
||||||
delete newEvent.custom[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eventIndex += 1;
|
eventIndex += 1;
|
||||||
} else if (isOntimeDelay(event)) {
|
} else if (isOntimeDelay(event)) {
|
||||||
newEvent = { ...delayDef, duration: event.duration, id };
|
newEvent = { ...delayDef, duration: event.duration, id };
|
||||||
@@ -128,14 +121,7 @@ export function parseRundown(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// for every field in custom, check that a key exists in customfields
|
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
|
||||||
for (const field in newNestedEvent.custom) {
|
|
||||||
if (!Object.hasOwn(parsedCustomFields, field)) {
|
|
||||||
emitError?.(`Custom field ${field} not found`);
|
|
||||||
delete newNestedEvent.custom[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eventIndex += 1;
|
eventIndex += 1;
|
||||||
|
|
||||||
if (newNestedEvent) {
|
if (newNestedEvent) {
|
||||||
@@ -145,16 +131,13 @@ export function parseRundown(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newEvent = {
|
newEvent = createBlock({ ...structuredClone(event), id });
|
||||||
...blockDef,
|
// ensure entries exist
|
||||||
title: event.title,
|
if (event.entries?.length > 0) {
|
||||||
note: event.note,
|
newEvent.entries = event.entries.filter((eventId) => Object.hasOwn(rundown.entries, eventId));
|
||||||
entries: event.entries?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [],
|
}
|
||||||
isNextDay: event.isNextDay,
|
// ensure custom fields are valid
|
||||||
colour: event.colour,
|
cleanupCustomFields(newEvent.custom, parsedCustomFields);
|
||||||
custom: { ...event.custom },
|
|
||||||
id,
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
emitError?.('Unknown event type, skipping');
|
emitError?.('Unknown event type, skipping');
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
isOntimeBlock,
|
isOntimeBlock,
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
|
OntimeBlock,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
PatchWithId,
|
PatchWithId,
|
||||||
@@ -35,23 +36,24 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
|||||||
}
|
}
|
||||||
|
|
||||||
// if the user provides a parent (inside a group), we make sure it exists and it is a group
|
// if the user provides a parent (inside a group), we make sure it exists and it is a group
|
||||||
let parent: EntryId | null = null;
|
let parent: OntimeBlock | null = null;
|
||||||
if ('parent' in eventData && eventData.parent != null) {
|
if ('parent' in eventData && eventData.parent != null) {
|
||||||
const maybeParent = rundown.entries[eventData.parent];
|
const maybeParent = rundown.entries[eventData.parent];
|
||||||
if (!maybeParent || !isOntimeBlock(maybeParent)) {
|
if (!maybeParent || !isOntimeBlock(maybeParent)) {
|
||||||
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
|
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
|
||||||
}
|
}
|
||||||
parent = eventData.parent;
|
parent = maybeParent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalise the position of the event in the rundown order
|
// normalise the position of the event in the rundown order
|
||||||
const afterId = getInsertAfterId(rundown, eventData?.after, eventData?.before);
|
const afterId = getInsertAfterId(rundown, parent, eventData?.after, eventData?.before);
|
||||||
|
|
||||||
// generate a fully formed entry from the patch
|
// generate a fully formed entry from the patch
|
||||||
const newEntry = generateEvent(rundown, eventData, afterId);
|
const newEntry = generateEvent(rundown, eventData, afterId);
|
||||||
|
|
||||||
// make mutations to rundown
|
// make mutations to rundown
|
||||||
rundownMutation.add(rundown, newEntry, afterId, parent);
|
rundownMutation.add(rundown, newEntry, afterId, parent?.id ?? null);
|
||||||
|
|
||||||
const { rundownMetadata, revision } = commit();
|
const { rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
// schedule the side effects
|
// schedule the side effects
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
CustomFields,
|
||||||
|
EntryCustomFields,
|
||||||
EntryId,
|
EntryId,
|
||||||
isOntimeBlock,
|
isOntimeBlock,
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
@@ -60,7 +62,7 @@ export function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDel
|
|||||||
throw new Error('Invalid event type');
|
throw new Error('Invalid event type');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
export function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
||||||
if (Object.keys(patchEvent).length === 0) {
|
if (Object.keys(patchEvent).length === 0) {
|
||||||
return originalEvent;
|
return originalEvent;
|
||||||
}
|
}
|
||||||
@@ -101,18 +103,52 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createBlockPatch(originalBlock: OntimeBlock, patchBlock: Partial<OntimeBlock>): OntimeBlock {
|
||||||
|
if (Object.keys(patchBlock).length === 0) {
|
||||||
|
return originalBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeTargetDuration = () => {
|
||||||
|
if (typeof patchBlock.targetDuration === 'number') {
|
||||||
|
return patchBlock.targetDuration;
|
||||||
|
}
|
||||||
|
if (patchBlock.targetDuration === null || patchBlock.targetDuration === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return originalBlock.targetDuration;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: originalBlock.id,
|
||||||
|
type: SupportedEntry.Block,
|
||||||
|
title: makeString(patchBlock.title, originalBlock.title),
|
||||||
|
note: makeString(patchBlock.note, originalBlock.note),
|
||||||
|
entries: patchBlock.entries ?? originalBlock.entries,
|
||||||
|
isNextDay: typeof patchBlock.isNextDay === 'boolean' ? patchBlock.isNextDay : originalBlock.isNextDay,
|
||||||
|
targetDuration: maybeTargetDuration(),
|
||||||
|
colour: makeString(patchBlock.colour, originalBlock.colour),
|
||||||
|
revision: originalBlock.revision,
|
||||||
|
timeStart: originalBlock.timeStart,
|
||||||
|
timeEnd: originalBlock.timeEnd,
|
||||||
|
duration: originalBlock.duration,
|
||||||
|
isFirstLinked: originalBlock.isFirstLinked,
|
||||||
|
custom: { ...originalBlock.custom, ...patchBlock.custom },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function for patching an existing event with new data
|
* Utility function for patching an existing event with new data
|
||||||
* Increments the revision of the event when applying the patch
|
* Increments the revision of the event when applying the patch
|
||||||
*/
|
*/
|
||||||
export function applyPatchToEntry<T extends OntimeEntry>(eventFromRundown: T, patch: Partial<T>): T {
|
export function applyPatchToEntry<T extends OntimeEntry>(eventFromRundown: T, patch: Partial<T>): T {
|
||||||
if (isOntimeEvent(eventFromRundown)) {
|
if (isOntimeEvent(eventFromRundown)) {
|
||||||
const newEvent = createPatch(eventFromRundown, patch as Partial<OntimeEvent>);
|
const newEvent = createEventPatch(eventFromRundown, patch as Partial<OntimeEvent>);
|
||||||
newEvent.revision++;
|
newEvent.revision++;
|
||||||
return newEvent as T;
|
return newEvent as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeBlock(eventFromRundown)) {
|
if (isOntimeBlock(eventFromRundown)) {
|
||||||
const newBlock: OntimeBlock = { ...eventFromRundown, ...patch };
|
const newBlock: OntimeBlock = createBlockPatch(eventFromRundown, patch as Partial<OntimeBlock>);
|
||||||
newBlock.revision++;
|
newBlock.revision++;
|
||||||
return newBlock as T;
|
return newBlock as T;
|
||||||
}
|
}
|
||||||
@@ -139,7 +175,7 @@ export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number
|
|||||||
cue,
|
cue,
|
||||||
...eventDef,
|
...eventDef,
|
||||||
};
|
};
|
||||||
const event = createPatch(baseEvent, eventArgs);
|
const event = createEventPatch(baseEvent, eventArgs);
|
||||||
return event;
|
return event;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,8 +198,8 @@ export function createBlock(patch?: Partial<OntimeBlock>): OntimeBlock {
|
|||||||
colour: makeString(patch.colour, ''),
|
colour: makeString(patch.colour, ''),
|
||||||
custom: patch.custom ?? {},
|
custom: patch.custom ?? {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
startTime: null,
|
timeStart: null,
|
||||||
endTime: null,
|
timeEnd: null,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
isFirstLinked: false,
|
isFirstLinked: false,
|
||||||
};
|
};
|
||||||
@@ -345,18 +381,41 @@ export function calculateDayOffset(
|
|||||||
* Receives an insertion order and returns the reference to an event ID
|
* Receives an insertion order and returns the reference to an event ID
|
||||||
* after which we will insert the new event
|
* after which we will insert the new event
|
||||||
*/
|
*/
|
||||||
export function getInsertAfterId(rundown: Rundown, afterId?: EntryId, beforeId?: EntryId): EntryId | null {
|
export function getInsertAfterId(
|
||||||
if (afterId) {
|
rundown: Rundown,
|
||||||
return afterId;
|
parent: OntimeBlock | null,
|
||||||
}
|
afterId?: EntryId,
|
||||||
|
beforeId?: EntryId,
|
||||||
|
): EntryId | null {
|
||||||
|
if (afterId) return afterId;
|
||||||
|
if (!beforeId) return null;
|
||||||
|
|
||||||
if (beforeId) {
|
/**
|
||||||
const atIndex = rundown.flatOrder.findIndex((id) => id === beforeId);
|
* At this point we know we want to insert before a given ID
|
||||||
if (atIndex < 1) return null;
|
* We need to check which list we should use to insert and find the event there
|
||||||
return rundown.flatOrder[atIndex - 1];
|
*/
|
||||||
}
|
const insertionList = parent ? parent.entries : rundown.order;
|
||||||
|
if (!insertionList || insertionList.length === 0) return null;
|
||||||
|
|
||||||
return null;
|
const atIndex = insertionList.findIndex((id) => id === beforeId);
|
||||||
|
if (atIndex < 1) return null;
|
||||||
|
return insertionList[atIndex - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitises custom fields in an entry by removing fields
|
||||||
|
* - if it does not exist in the project
|
||||||
|
* - if the value is empty string
|
||||||
|
* Mutates the entryCustomFields object
|
||||||
|
*/
|
||||||
|
export function cleanupCustomFields(entryCustomFields: EntryCustomFields, projectCustomFields: CustomFields) {
|
||||||
|
for (const field in entryCustomFields) {
|
||||||
|
if (!Object.hasOwn(projectCustomFields, field)) {
|
||||||
|
delete entryCustomFields[field];
|
||||||
|
} else if (entryCustomFields[field] === '') {
|
||||||
|
delete entryCustomFields[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
colour: 'hotpink',
|
colour: 'hotpink',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
startTime: null,
|
timeStart: null,
|
||||||
endTime: null,
|
timeEnd: null,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
isFirstLinked: false,
|
isFirstLinked: false,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -216,8 +216,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
startTime: null,
|
timeStart: null,
|
||||||
endTime: null,
|
timeEnd: null,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
isFirstLinked: false,
|
isFirstLinked: false,
|
||||||
},
|
},
|
||||||
@@ -379,8 +379,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
startTime: null,
|
timeStart: null,
|
||||||
endTime: null,
|
timeEnd: null,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
isFirstLinked: false,
|
isFirstLinked: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ export const block: Omit<OntimeBlock, 'id'> = {
|
|||||||
custom: {},
|
custom: {},
|
||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
revision: 0, // calculated at runtime
|
revision: 0, // calculated at runtime
|
||||||
startTime: null, // calculated at runtime
|
timeStart: null, // calculated at runtime
|
||||||
endTime: null, // calculated at runtime
|
timeEnd: null, // calculated at runtime
|
||||||
duration: 0, // calculated at runtime
|
duration: 0, // calculated at runtime
|
||||||
isFirstLinked: false, // calculated at runtime
|
isFirstLinked: false, // calculated at runtime
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export function loadRoll(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// in case we were unable to find anything, we load the first event
|
// in case we were unable to find anything, we load the first event
|
||||||
console.log('returning first event');
|
|
||||||
return { event: rundown.entries[firstEventId] as PlayableEvent, index: 0, isPending: true };
|
return { event: rundown.entries[firstEventId] as PlayableEvent, index: 0, isPending: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,8 +172,8 @@
|
|||||||
"skip": false,
|
"skip": false,
|
||||||
"custom": {},
|
"custom": {},
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"startTime": null,
|
"timeStart": null,
|
||||||
"endTime": null,
|
"timeEnd": null,
|
||||||
"duration": 0,
|
"duration": 0,
|
||||||
"isFirstLinked": false
|
"isFirstLinked": false
|
||||||
},
|
},
|
||||||
@@ -327,8 +327,8 @@
|
|||||||
"skip": false,
|
"skip": false,
|
||||||
"custom": {},
|
"custom": {},
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"startTime": null,
|
"timeStart": null,
|
||||||
"endTime": null,
|
"timeEnd": null,
|
||||||
"duration": 0,
|
"duration": 0,
|
||||||
"isFirstLinked": false,
|
"isFirstLinked": false,
|
||||||
"numEvents": 0
|
"numEvents": 0
|
||||||
|
|||||||
@@ -13,3 +13,40 @@ test('cuesheet displays events', async ({ page }) => {
|
|||||||
const rowCount = await page.locator('#cuesheet tbody tr').count();
|
const rowCount = await page.locator('#cuesheet tbody tr').count();
|
||||||
expect(rowCount).toBe(16);
|
expect(rowCount).toBe(16);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cuesheet custom field with Lexical editor', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:4001/cuesheet');
|
||||||
|
|
||||||
|
// Locate a custom field cell. Assuming 'Custom Col 1' is the header for a string custom field.
|
||||||
|
// And assuming the first data row is a suitable target.
|
||||||
|
// Adjust selectors based on actual table structure and data.
|
||||||
|
const customFieldCell = page.locator('#cuesheet tbody tr:first-child td[data-column-id="customCol1"]');
|
||||||
|
|
||||||
|
// 1. Verify initial display (non-editable text)
|
||||||
|
// This requires knowing the initial text or checking it's not an input/editor
|
||||||
|
await expect(customFieldCell.locator('div[contenteditable="true"]')).not.toBeVisible();
|
||||||
|
const initialText = await customFieldCell.innerText();
|
||||||
|
|
||||||
|
// 2. Click to activate editor
|
||||||
|
await customFieldCell.click();
|
||||||
|
const lexicalEditor = customFieldCell.locator('div[contenteditable="true"]');
|
||||||
|
await expect(lexicalEditor).toBeVisible();
|
||||||
|
await expect(lexicalEditor).toBeFocused();
|
||||||
|
|
||||||
|
// 3. Edit text
|
||||||
|
const newText = 'Updated text via Playwright';
|
||||||
|
await lexicalEditor.fill(newText);
|
||||||
|
await expect(lexicalEditor).toHaveText(newText);
|
||||||
|
|
||||||
|
// 4. Click outside (or blur) to save
|
||||||
|
// Clicking another element to cause blur. A more robust way might be needed depending on implementation.
|
||||||
|
await page.getByText('Eurovision Song Contest').click(); // Click title or header
|
||||||
|
await expect(lexicalEditor).not.toBeVisible(); // Editor should be gone
|
||||||
|
await expect(customFieldCell).toHaveText(newText); // Cell should display new text
|
||||||
|
|
||||||
|
// 5. Verify other cell types are unaffected (optional, good for regression)
|
||||||
|
// This would involve locating other cell types and ensuring they didn't change.
|
||||||
|
// For example, check a 'Title' cell:
|
||||||
|
const titleCell = page.locator('#cuesheet tbody tr:first-child td[data-column-id="title"]');
|
||||||
|
await expect(titleCell.locator('div[contenteditable="true"]')).not.toBeVisible(); // Assuming title is not lexical
|
||||||
|
});
|
||||||
|
|||||||
Vendored
+4
-4
@@ -190,8 +190,8 @@
|
|||||||
"skip": false,
|
"skip": false,
|
||||||
"custom": {},
|
"custom": {},
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"startTime": null,
|
"timeStart": null,
|
||||||
"endTime": null,
|
"timeEnd": null,
|
||||||
"duration": 0,
|
"duration": 0,
|
||||||
"isFirstLinked": false
|
"isFirstLinked": false
|
||||||
},
|
},
|
||||||
@@ -345,8 +345,8 @@
|
|||||||
"skip": false,
|
"skip": false,
|
||||||
"custom": {},
|
"custom": {},
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"startTime": null,
|
"timeStart": null,
|
||||||
"endTime": null,
|
"timeEnd": null,
|
||||||
"duration": 0,
|
"duration": 0,
|
||||||
"isFirstLinked": false
|
"isFirstLinked": false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export type OntimeBlock = OntimeBaseEvent & {
|
|||||||
custom: EntryCustomFields;
|
custom: EntryCustomFields;
|
||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
revision: number;
|
revision: number;
|
||||||
startTime: MaybeNumber; // calculated at runtime
|
timeStart: MaybeNumber; // calculated at runtime
|
||||||
endTime: MaybeNumber; // calculated at runtime
|
timeEnd: MaybeNumber; // calculated at runtime
|
||||||
duration: number; // calculated at runtime
|
duration: number; // calculated at runtime
|
||||||
isFirstLinked: boolean; // calculated at runtime, whether the first event is linked
|
isFirstLinked: boolean; // calculated at runtime, whether the first event is linked
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ const config: PlaywrightTestConfig = {
|
|||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'turbo run dev --filter=ontime-server',
|
command: 'npx tsx ./src/index.ts', // More direct command
|
||||||
|
cwd: './apps/server', // Set working directory
|
||||||
port: 4001,
|
port: 4001,
|
||||||
|
// url: 'http://localhost:4001/editor', // Removed URL, rely on port check
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: true,
|
||||||
timeout: 60 * 1000,
|
timeout: 120 * 1000,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'development', // tsx might need this
|
||||||
|
IS_TEST: 'true',
|
||||||
|
}
|
||||||
},
|
},
|
||||||
use: {
|
use: {
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
|
|||||||
Generated
+261
-1
@@ -113,6 +113,9 @@ importers:
|
|||||||
'@fontsource/open-sans':
|
'@fontsource/open-sans':
|
||||||
specifier: ^5.0.28
|
specifier: ^5.0.28
|
||||||
version: 5.0.28
|
version: 5.0.28
|
||||||
|
'@lexical/react':
|
||||||
|
specifier: ^0.17.0
|
||||||
|
version: 0.17.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(yjs@13.6.27)
|
||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^7.17.2
|
specifier: ^7.17.2
|
||||||
version: 7.17.2(react@18.3.1)
|
version: 7.17.2(react@18.3.1)
|
||||||
@@ -143,6 +146,9 @@ importers:
|
|||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^10.10.0
|
specifier: ^10.10.0
|
||||||
version: 10.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 10.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
lexical:
|
||||||
|
specifier: ^0.17.0
|
||||||
|
version: 0.17.1
|
||||||
prismjs:
|
prismjs:
|
||||||
specifier: ^1.29.0
|
specifier: ^1.29.0
|
||||||
version: 1.29.0
|
version: 1.29.0
|
||||||
@@ -1837,6 +1843,77 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.25':
|
'@jridgewell/trace-mapping@0.3.25':
|
||||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||||
|
|
||||||
|
'@lexical/clipboard@0.17.1':
|
||||||
|
resolution: {integrity: sha512-OVqnEfWX8XN5xxuMPo6BfgGKHREbz++D5V5ISOiml0Z8fV/TQkdgwqbBJcUdJHGRHWSUwdK7CWGs/VALvVvZyw==}
|
||||||
|
|
||||||
|
'@lexical/code@0.17.1':
|
||||||
|
resolution: {integrity: sha512-ZspfTm6g6dN3nAb4G5bPp3SqxzdkB/bjGfa0uRKMU6/eBKtrMUgZsGxt0a8JRZ1eq2TZrQhx+l1ceRoLXii/bQ==}
|
||||||
|
|
||||||
|
'@lexical/devtools-core@0.17.1':
|
||||||
|
resolution: {integrity: sha512-SzL1EX9Rt5GptIo87t6nDxAc9TtYtl6DyAPNz/sCltspdd69KQgs23sTRa26/tkNFCS1jziRN7vpN3mlnmm5wA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17.x'
|
||||||
|
react-dom: '>=17.x'
|
||||||
|
|
||||||
|
'@lexical/dragon@0.17.1':
|
||||||
|
resolution: {integrity: sha512-lhBRKP7RlhiVCLtF0qiNqmMhEO6cQB43sMe7d4bvuY1G2++oKY/XAJPg6QJZdXRrCGRQ6vZ26QRNhRPmCxL5Ng==}
|
||||||
|
|
||||||
|
'@lexical/hashtag@0.17.1':
|
||||||
|
resolution: {integrity: sha512-XtP0BI8vEewAe7tzq9MC49UPUvuChuNJI/jqFp+ezZlt/RUq0BClQCOPuSlrTJhluvE2rWnUnOnVMk8ILRvggQ==}
|
||||||
|
|
||||||
|
'@lexical/history@0.17.1':
|
||||||
|
resolution: {integrity: sha512-OU/ohajz4FXchUhghsWC7xeBPypFe50FCm5OePwo767G7P233IztgRKIng2pTT4zhCPW7S6Mfl53JoFHKehpWA==}
|
||||||
|
|
||||||
|
'@lexical/html@0.17.1':
|
||||||
|
resolution: {integrity: sha512-yGG+K2DXl7Wn2DpNuZ0Y3uCHJgfHkJN3/MmnFb4jLnH1FoJJiuy7WJb/BRRh9H+6xBJ9v70iv+kttDJ0u1xp5w==}
|
||||||
|
|
||||||
|
'@lexical/link@0.17.1':
|
||||||
|
resolution: {integrity: sha512-qFJEKBesZAtR8kfJfIVXRFXVw6dwcpmGCW7duJbtBRjdLjralOxrlVKyFhW9PEXGhi4Mdq2Ux16YnnDncpORdQ==}
|
||||||
|
|
||||||
|
'@lexical/list@0.17.1':
|
||||||
|
resolution: {integrity: sha512-k9ZnmQuBvW+xVUtWJZwoGtiVG2cy+hxzkLGU4jTq1sqxRIoSeGcjvhFAK8JSEj4i21SgkB1FmkWXoYK5kbwtRA==}
|
||||||
|
|
||||||
|
'@lexical/mark@0.17.1':
|
||||||
|
resolution: {integrity: sha512-V82SSRjvygmV+ZMwVpy5gwgr2ZDrJpl3TvEDO+G5I4SDSjbgvua8hO4dKryqiDVlooxQq9dsou0GrZ9Qtm6rYg==}
|
||||||
|
|
||||||
|
'@lexical/markdown@0.17.1':
|
||||||
|
resolution: {integrity: sha512-uexR9snyT54jfQTrbr/GZAtzX+8Oyykr4p1HS0vCVL1KU5MDuP2PoyFfOv3rcfB2TASc+aYiINhU2gSXzwCHNg==}
|
||||||
|
|
||||||
|
'@lexical/offset@0.17.1':
|
||||||
|
resolution: {integrity: sha512-fX0ZSIFWwUKAjxf6l21vyXFozJGExKWyWxA+EMuOloNAGotHnAInxep0Mt8t/xcvHs7luuyQUxEPw7YrTJP7aw==}
|
||||||
|
|
||||||
|
'@lexical/overflow@0.17.1':
|
||||||
|
resolution: {integrity: sha512-oElVDq486R3rO2+Zz0EllXJGpW3tN0tfcH+joZ5h36+URKuNeKddqkJuDRvgSLOr9l8Jhtv3+/YKduPJVKMz6w==}
|
||||||
|
|
||||||
|
'@lexical/plain-text@0.17.1':
|
||||||
|
resolution: {integrity: sha512-CSvi4j1a4ame0OAvOKUCCmn2XrNsWcST4lExGTa9Ei/VIh8IZ+a97h4Uby8T3lqOp10x+oiizYWzY30pb9QaBg==}
|
||||||
|
|
||||||
|
'@lexical/react@0.17.1':
|
||||||
|
resolution: {integrity: sha512-DI4k25tO0E1WyozrjaLgKMOmLjOB7+39MT4eZN9brPlU7g+w0wzdGbTZUPgPmFGIKPK+MSLybCwAJCK97j8HzQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17.x'
|
||||||
|
react-dom: '>=17.x'
|
||||||
|
|
||||||
|
'@lexical/rich-text@0.17.1':
|
||||||
|
resolution: {integrity: sha512-T3kvj4P1OpedX9jvxN3WN8NP1Khol6mCW2ScFIRNRz2dsXgyN00thH1Q1J/uyu7aKyGS7rzcY0rb1Pz1qFufqQ==}
|
||||||
|
|
||||||
|
'@lexical/selection@0.17.1':
|
||||||
|
resolution: {integrity: sha512-qBKVn+lMV2YIoyRELNr1/QssXx/4c0id9NCB/BOuYlG8du5IjviVJquEF56NEv2t0GedDv4BpUwkhXT2QbNAxA==}
|
||||||
|
|
||||||
|
'@lexical/table@0.17.1':
|
||||||
|
resolution: {integrity: sha512-2fUYPmxhyuMQX3MRvSsNaxbgvwGNJpHaKx1Ldc+PT2MvDZ6ALZkfsxbi0do54Q3i7dOon8/avRp4TuVaCnqvoA==}
|
||||||
|
|
||||||
|
'@lexical/text@0.17.1':
|
||||||
|
resolution: {integrity: sha512-zD2pAGXaMfPpT8PeNrx3+n0+jGnQORHyn0NEBO+hnyacKfUq5z5sI6Gebsq5NwH789bRadmJM5LvX5w8fsuv6w==}
|
||||||
|
|
||||||
|
'@lexical/utils@0.17.1':
|
||||||
|
resolution: {integrity: sha512-jCQER5EsvhLNxKH3qgcpdWj/necUb82Xjp8qWQ3c0tyL07hIRm2tDRA/s9mQmvcP855HEZSmGVmR5SKtkcEAVg==}
|
||||||
|
|
||||||
|
'@lexical/yjs@0.17.1':
|
||||||
|
resolution: {integrity: sha512-9mn5PDtaH5uLMH6hQ59EAx5FkRzmJJFcVs3E6zSIbtgkG3UASR3CFEfgsLKTjl/GC5NnTGuMck+jXaupDVBhOg==}
|
||||||
|
peerDependencies:
|
||||||
|
yjs: '>=13.5.22'
|
||||||
|
|
||||||
'@malept/cross-spawn-promise@1.1.1':
|
'@malept/cross-spawn-promise@1.1.1':
|
||||||
resolution: {integrity: sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==}
|
resolution: {integrity: sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -4041,6 +4118,9 @@ packages:
|
|||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
isomorphic.js@0.2.5:
|
||||||
|
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
|
||||||
|
|
||||||
jackspeak@3.4.3:
|
jackspeak@3.4.3:
|
||||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||||
|
|
||||||
@@ -4134,6 +4214,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
lexical@0.17.1:
|
||||||
|
resolution: {integrity: sha512-72/MhR7jqmyqD10bmJw8gztlCm4KDDT+TPtU4elqXrEvHoO5XENi34YAEUD9gIkPfqSwyLa9mwAX1nKzIr5xEA==}
|
||||||
|
|
||||||
|
lib0@0.2.109:
|
||||||
|
resolution: {integrity: sha512-jP0gbnyW0kwlx1Atc4dcHkBbrVAkdHjuyHxtClUPYla7qCmwIif1qZ6vQeJdR5FrOVdn26HvQT0ko01rgW7/Xw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
lines-and-columns@1.2.4:
|
lines-and-columns@1.2.4:
|
||||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||||
|
|
||||||
@@ -4717,6 +4805,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
react: ^18.3.1
|
||||||
|
|
||||||
|
react-error-boundary@3.1.4:
|
||||||
|
resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==}
|
||||||
|
engines: {node: '>=10', npm: '>=6'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.13.1'
|
||||||
|
|
||||||
react-fast-compare@3.2.1:
|
react-fast-compare@3.2.1:
|
||||||
resolution: {integrity: sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg==}
|
resolution: {integrity: sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg==}
|
||||||
|
|
||||||
@@ -5706,6 +5800,10 @@ packages:
|
|||||||
yauzl@2.10.0:
|
yauzl@2.10.0:
|
||||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||||
|
|
||||||
|
yjs@13.6.27:
|
||||||
|
resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==}
|
||||||
|
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
|
||||||
|
|
||||||
yocto-queue@0.1.0:
|
yocto-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -7328,6 +7426,151 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.1
|
'@jridgewell/resolve-uri': 3.1.1
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
|
|
||||||
|
'@lexical/clipboard@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/html': 0.17.1
|
||||||
|
'@lexical/list': 0.17.1
|
||||||
|
'@lexical/selection': 0.17.1
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/code@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
prismjs: 1.29.0
|
||||||
|
|
||||||
|
'@lexical/devtools-core@0.17.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/html': 0.17.1
|
||||||
|
'@lexical/link': 0.17.1
|
||||||
|
'@lexical/mark': 0.17.1
|
||||||
|
'@lexical/table': 0.17.1
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
|
'@lexical/dragon@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/hashtag@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/history@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/html@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/selection': 0.17.1
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/link@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/list@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/mark@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/markdown@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/code': 0.17.1
|
||||||
|
'@lexical/link': 0.17.1
|
||||||
|
'@lexical/list': 0.17.1
|
||||||
|
'@lexical/rich-text': 0.17.1
|
||||||
|
'@lexical/text': 0.17.1
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/offset@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/overflow@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/plain-text@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/clipboard': 0.17.1
|
||||||
|
'@lexical/selection': 0.17.1
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/react@0.17.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(yjs@13.6.27)':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/clipboard': 0.17.1
|
||||||
|
'@lexical/code': 0.17.1
|
||||||
|
'@lexical/devtools-core': 0.17.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@lexical/dragon': 0.17.1
|
||||||
|
'@lexical/hashtag': 0.17.1
|
||||||
|
'@lexical/history': 0.17.1
|
||||||
|
'@lexical/link': 0.17.1
|
||||||
|
'@lexical/list': 0.17.1
|
||||||
|
'@lexical/mark': 0.17.1
|
||||||
|
'@lexical/markdown': 0.17.1
|
||||||
|
'@lexical/overflow': 0.17.1
|
||||||
|
'@lexical/plain-text': 0.17.1
|
||||||
|
'@lexical/rich-text': 0.17.1
|
||||||
|
'@lexical/selection': 0.17.1
|
||||||
|
'@lexical/table': 0.17.1
|
||||||
|
'@lexical/text': 0.17.1
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
'@lexical/yjs': 0.17.1(yjs@13.6.27)
|
||||||
|
lexical: 0.17.1
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
react-error-boundary: 3.1.4(react@18.3.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- yjs
|
||||||
|
|
||||||
|
'@lexical/rich-text@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/clipboard': 0.17.1
|
||||||
|
'@lexical/selection': 0.17.1
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/selection@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/table@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/utils': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/text@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/utils@0.17.1':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/list': 0.17.1
|
||||||
|
'@lexical/selection': 0.17.1
|
||||||
|
'@lexical/table': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
|
||||||
|
'@lexical/yjs@0.17.1(yjs@13.6.27)':
|
||||||
|
dependencies:
|
||||||
|
'@lexical/offset': 0.17.1
|
||||||
|
lexical: 0.17.1
|
||||||
|
yjs: 13.6.27
|
||||||
|
|
||||||
'@malept/cross-spawn-promise@1.1.1':
|
'@malept/cross-spawn-promise@1.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
@@ -9997,6 +10240,8 @@ snapshots:
|
|||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
isomorphic.js@0.2.5: {}
|
||||||
|
|
||||||
jackspeak@3.4.3:
|
jackspeak@3.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@isaacs/cliui': 8.0.2
|
'@isaacs/cliui': 8.0.2
|
||||||
@@ -10122,6 +10367,12 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
lexical@0.17.1: {}
|
||||||
|
|
||||||
|
lib0@0.2.109:
|
||||||
|
dependencies:
|
||||||
|
isomorphic.js: 0.2.5
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
@@ -10628,7 +10879,7 @@ snapshots:
|
|||||||
|
|
||||||
react-clientside-effect@1.2.6(react@18.3.1):
|
react-clientside-effect@1.2.6(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.24.5
|
'@babel/runtime': 7.27.6
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
react-colorful@5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
react-colorful@5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
@@ -10642,6 +10893,11 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
scheduler: 0.23.2
|
scheduler: 0.23.2
|
||||||
|
|
||||||
|
react-error-boundary@3.1.4(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.27.6
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
react-fast-compare@3.2.1: {}
|
react-fast-compare@3.2.1: {}
|
||||||
|
|
||||||
react-fast-compare@3.2.2: {}
|
react-fast-compare@3.2.2: {}
|
||||||
@@ -11689,6 +11945,10 @@ snapshots:
|
|||||||
buffer-crc32: 0.2.13
|
buffer-crc32: 0.2.13
|
||||||
fd-slicer: 1.1.0
|
fd-slicer: 1.1.0
|
||||||
|
|
||||||
|
yjs@13.6.27:
|
||||||
|
dependencies:
|
||||||
|
lib0: 0.2.109
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zip-stream@4.1.1:
|
zip-stream@4.1.1:
|
||||||
|
|||||||
Reference in New Issue
Block a user