mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-05 15:33:59 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e40a0eebec |
@@ -153,6 +153,20 @@ export async function postCloneEntry(
|
|||||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
|
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PastePayload = {
|
||||||
|
entryIds: EntryId[];
|
||||||
|
sourceRundownId: string;
|
||||||
|
afterId?: EntryId;
|
||||||
|
beforeId?: EntryId;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request for pasting entries into the active rundown
|
||||||
|
*/
|
||||||
|
export async function postPasteEntries(rundownId: RundownId, data: PastePayload): Promise<AxiosResponse<Rundown>> {
|
||||||
|
return axios.post(`${rundownPath}/${rundownId}/paste`, data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request for grouping a list of entries into a group
|
* HTTP request for grouping a list of entries into a group
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -35,11 +35,13 @@ import { useCallback, useMemo } from 'react';
|
|||||||
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
|
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
import {
|
import {
|
||||||
|
PastePayload,
|
||||||
ReorderEntry,
|
ReorderEntry,
|
||||||
deleteEntries,
|
deleteEntries,
|
||||||
patchReorderEntry,
|
patchReorderEntry,
|
||||||
postAddEntry,
|
postAddEntry,
|
||||||
postCloneEntry,
|
postCloneEntry,
|
||||||
|
postPasteEntries,
|
||||||
putBatchEditEvents,
|
putBatchEditEvents,
|
||||||
putEditEntry,
|
putEditEntry,
|
||||||
requestApplyDelay,
|
requestApplyDelay,
|
||||||
@@ -270,6 +272,35 @@ export const useEntryActions = () => {
|
|||||||
[cloneEntryMutation, getCurrentRundownData],
|
[cloneEntryMutation, getCurrentRundownData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to paste entries
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const { mutateAsync: pasteEntriesMutation } = useMutation({
|
||||||
|
mutationFn: ([rundownId, data]: [string, PastePayload]) => postPasteEntries(rundownId, data),
|
||||||
|
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste entries from copy store into the active rundown
|
||||||
|
*/
|
||||||
|
const pasteEntries = useCallback(
|
||||||
|
async (data: PastePayload) => {
|
||||||
|
try {
|
||||||
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
|
await pasteEntriesMutation([rundownId, data]);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error pasting entries', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pasteEntriesMutation, getCurrentRundownData],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to update existing entry
|
* Calls mutation to update existing entry
|
||||||
* @private
|
* @private
|
||||||
@@ -919,6 +950,7 @@ export const useEntryActions = () => {
|
|||||||
clone,
|
clone,
|
||||||
deleteEntry,
|
deleteEntry,
|
||||||
deleteAllEntries,
|
deleteAllEntries,
|
||||||
|
pasteEntries,
|
||||||
ungroup,
|
ungroup,
|
||||||
getEntryById,
|
getEntryById,
|
||||||
groupEntries,
|
groupEntries,
|
||||||
@@ -935,6 +967,7 @@ export const useEntryActions = () => {
|
|||||||
clone,
|
clone,
|
||||||
deleteEntry,
|
deleteEntry,
|
||||||
deleteAllEntries,
|
deleteAllEntries,
|
||||||
|
pasteEntries,
|
||||||
ungroup,
|
ungroup,
|
||||||
getEntryById,
|
getEntryById,
|
||||||
groupEntries,
|
groupEntries,
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
type EntryCopyStore = {
|
type EntryCopyStore = {
|
||||||
entryCopyId: string | null;
|
entryIds: Set<string>;
|
||||||
entryCopyMode: 'copy' | 'cut';
|
sourceRundownId: string | null;
|
||||||
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
|
setCopyEntries: (ids: string[], rundownId: string) => void;
|
||||||
|
clearCopy: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
|
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
|
||||||
entryCopyId: null,
|
entryIds: new Set(),
|
||||||
entryCopyMode: 'copy',
|
sourceRundownId: null,
|
||||||
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
|
setCopyEntries: (ids: string[], rundownId: string) =>
|
||||||
set({ entryCopyId, entryCopyMode: mode }),
|
set({ entryIds: new Set(ids), sourceRundownId: rundownId }),
|
||||||
|
clearCopy: () => set({ entryIds: new Set(), sourceRundownId: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { TbFlagFilled } from 'react-icons/tb';
|
|||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
|
||||||
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
|
||||||
import { RundownMetadataObject, lastMetadataKey } from '../../common/utils/rundownMetadata';
|
import { RundownMetadataObject, lastMetadataKey } from '../../common/utils/rundownMetadata';
|
||||||
import { AppMode } from '../../ontimeConfig';
|
import { AppMode } from '../../ontimeConfig';
|
||||||
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
||||||
@@ -64,7 +63,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
|
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
|
||||||
|
|
||||||
const entryActions = useEntryActionsContext();
|
const entryActions = useEntryActionsContext();
|
||||||
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
|
|
||||||
// cursor
|
// cursor
|
||||||
const { editorMode } = useEditorFollowMode();
|
const { editorMode } = useEditorFollowMode();
|
||||||
@@ -105,9 +104,10 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useRundownKeyboard({
|
useRundownKeyboard({
|
||||||
cursor,
|
cursor,
|
||||||
|
rundownId: id,
|
||||||
|
selectedEvents,
|
||||||
commands,
|
commands,
|
||||||
clearSelectedEvents,
|
clearSelectedEvents,
|
||||||
setEntryCopyId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// DND handlers
|
// DND handlers
|
||||||
|
|||||||
@@ -96,14 +96,6 @@ function EventEditorEmpty() {
|
|||||||
<Kbd>C</Kbd>
|
<Kbd>C</Kbd>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>Cut selected entry</td>
|
|
||||||
<td>
|
|
||||||
<Kbd>{deviceMod}</Kbd>
|
|
||||||
<AuxKey>+</AuxKey>
|
|
||||||
<Kbd>X</Kbd>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>Paste above</td>
|
<td>Paste above</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function useRundownCommands({
|
|||||||
selectEntry: applySelection,
|
selectEntry: applySelection,
|
||||||
handleCollapseGroup,
|
handleCollapseGroup,
|
||||||
}: UseRundownCommandsOptions) {
|
}: UseRundownCommandsOptions) {
|
||||||
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
|
const { addEntry, clone, deleteEntry, move, pasteEntries } = entryActions;
|
||||||
|
|
||||||
const deleteAtCursor = useCallback(
|
const deleteAtCursor = useCallback(
|
||||||
(cursor: string | null) => {
|
(cursor: string | null) => {
|
||||||
@@ -40,52 +40,21 @@ export function useRundownCommands({
|
|||||||
[entries, flatOrder, deleteEntry, applySelection],
|
[entries, flatOrder, deleteEntry, applySelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertCopyAtId = useCallback(
|
const pasteAtCursor = useCallback(
|
||||||
(atId: EntryId | null, above = false) => {
|
(cursor: EntryId | null, above = false) => {
|
||||||
// lazily get the value from the store
|
const { entryIds, sourceRundownId } = useEntryCopy.getState();
|
||||||
const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
|
if (entryIds.size === 0 || !sourceRundownId) {
|
||||||
if (entryCopyId === null || !entries[entryCopyId]) {
|
|
||||||
// we cant clone without selection
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalisedAtId = atId;
|
pasteEntries({
|
||||||
|
entryIds: Array.from(entryIds),
|
||||||
const elementToCopy = entries[entryCopyId];
|
sourceRundownId,
|
||||||
const refElement = atId ? entries[atId] : undefined;
|
afterId: above ? undefined : (cursor ?? undefined),
|
||||||
|
beforeId: above ? (cursor ?? undefined) : undefined,
|
||||||
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
|
|
||||||
normalisedAtId = refElement.parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entryCopyMode === 'cut') {
|
|
||||||
if (!normalisedAtId) {
|
|
||||||
const firstId = flatOrder[0];
|
|
||||||
if (!firstId || firstId === entryCopyId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reorderEntry(entryCopyId, firstId, 'before')
|
|
||||||
.then(() => setEntryCopyId(null))
|
|
||||||
.catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (normalisedAtId === entryCopyId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const placement = above ? 'before' : 'after';
|
|
||||||
reorderEntry(entryCopyId, normalisedAtId, placement)
|
|
||||||
.then(() => setEntryCopyId(null))
|
|
||||||
.catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clone(entryCopyId, {
|
|
||||||
after: above ? undefined : (normalisedAtId ?? undefined),
|
|
||||||
// if we don't have a cursor add the new event on top
|
|
||||||
before: above ? (normalisedAtId ?? undefined) : undefined,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[entries, flatOrder, clone, reorderEntry],
|
[pasteEntries],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,7 +186,7 @@ export function useRundownCommands({
|
|||||||
return {
|
return {
|
||||||
cloneEntry,
|
cloneEntry,
|
||||||
deleteAtCursor,
|
deleteAtCursor,
|
||||||
insertCopyAtId,
|
pasteAtCursor,
|
||||||
insertAtId,
|
insertAtId,
|
||||||
selectGroup,
|
selectGroup,
|
||||||
selectEntry,
|
selectEntry,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useEventSelection } from '../useEventSelection';
|
|||||||
|
|
||||||
interface UseRundownKeyboardOptions {
|
interface UseRundownKeyboardOptions {
|
||||||
cursor: EntryId | null;
|
cursor: EntryId | null;
|
||||||
|
rundownId: string | undefined;
|
||||||
|
selectedEvents: Set<EntryId>;
|
||||||
commands: {
|
commands: {
|
||||||
selectEntry: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
|
selectEntry: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
|
||||||
selectGroup: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
|
selectGroup: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
|
||||||
@@ -15,10 +17,9 @@ interface UseRundownKeyboardOptions {
|
|||||||
moveEntry: (cursor: EntryId | null, direction: 'up' | 'down') => void;
|
moveEntry: (cursor: EntryId | null, direction: 'up' | 'down') => void;
|
||||||
deleteAtCursor: (cursor: EntryId | null) => void;
|
deleteAtCursor: (cursor: EntryId | null) => void;
|
||||||
insertAtId: (patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: EntryId | null, above?: boolean) => void;
|
insertAtId: (patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: EntryId | null, above?: boolean) => void;
|
||||||
insertCopyAtId: (atId: EntryId | null, above?: boolean) => void;
|
pasteAtCursor: (cursor: EntryId | null, above?: boolean) => void;
|
||||||
};
|
};
|
||||||
clearSelectedEvents: () => void;
|
clearSelectedEvents: () => void;
|
||||||
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,9 +38,10 @@ function isEditableElement(target: EventTarget | null): boolean {
|
|||||||
|
|
||||||
export function useRundownKeyboard({
|
export function useRundownKeyboard({
|
||||||
cursor,
|
cursor,
|
||||||
|
rundownId,
|
||||||
|
selectedEvents,
|
||||||
commands,
|
commands,
|
||||||
clearSelectedEvents,
|
clearSelectedEvents,
|
||||||
setEntryCopyId,
|
|
||||||
}: UseRundownKeyboardOptions) {
|
}: UseRundownKeyboardOptions) {
|
||||||
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
||||||
|
|
||||||
@@ -138,7 +140,7 @@ export function useRundownKeyboard({
|
|||||||
'Escape',
|
'Escape',
|
||||||
() => {
|
() => {
|
||||||
clearSelectedEvents();
|
clearSelectedEvents();
|
||||||
setEntryCopyId(null);
|
useEntryCopy.getState().clearCopy();
|
||||||
},
|
},
|
||||||
{ preventDefault: true, usePhysicalKeys: true },
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
],
|
],
|
||||||
@@ -192,33 +194,25 @@ export function useRundownKeyboard({
|
|||||||
[
|
[
|
||||||
'mod + C',
|
'mod + C',
|
||||||
(event) => {
|
(event) => {
|
||||||
if (cursor === null || isEditableElement(event.target)) {
|
if (cursor === null || isEditableElement(event.target) || !rundownId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setEntryCopyId(cursor);
|
|
||||||
},
|
// if multiple events are selected, copy all of them; otherwise copy the cursor entry
|
||||||
{ usePhysicalKeys: true },
|
const ids = selectedEvents.size > 1 ? Array.from(selectedEvents) : [cursor];
|
||||||
],
|
useEntryCopy.getState().setCopyEntries(ids, rundownId);
|
||||||
[
|
|
||||||
'mod + X',
|
|
||||||
(event) => {
|
|
||||||
if (cursor === null || isEditableElement(event.target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
setEntryCopyId(cursor, 'cut');
|
|
||||||
},
|
},
|
||||||
{ usePhysicalKeys: true },
|
{ usePhysicalKeys: true },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'mod + V',
|
'mod + V',
|
||||||
(event) => {
|
(event) => {
|
||||||
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
|
if (isEditableElement(event.target) || useEntryCopy.getState().entryIds.size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
commands.insertCopyAtId(cursor);
|
commands.pasteAtCursor(cursor);
|
||||||
},
|
},
|
||||||
{ usePhysicalKeys: true },
|
{ usePhysicalKeys: true },
|
||||||
],
|
],
|
||||||
@@ -226,11 +220,11 @@ export function useRundownKeyboard({
|
|||||||
[
|
[
|
||||||
'mod + shift + V',
|
'mod + shift + V',
|
||||||
(event) => {
|
(event) => {
|
||||||
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
|
if (isEditableElement(event.target) || useEntryCopy.getState().entryIds.size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
commands.insertCopyAtId(cursor, true);
|
commands.pasteAtCursor(cursor, true);
|
||||||
},
|
},
|
||||||
{ usePhysicalKeys: true },
|
{ usePhysicalKeys: true },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,10 +17,6 @@
|
|||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.copyTarget {
|
|
||||||
outline: 1px dashed $blue-500;
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag {
|
.drag {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { IoCheckmarkDone, IoClose, IoReorderTwo } from 'react-icons/io5';
|
|||||||
|
|
||||||
import Button from '../../../common/components/buttons/Button';
|
import Button from '../../../common/components/buttons/Button';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import DelayInput from './DelayInput';
|
import DelayInput from './DelayInput';
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
|
|||||||
|
|
||||||
const { applyDelay, deleteEntry } = useEntryActionsContext();
|
const { applyDelay, deleteEntry } = useEntryActionsContext();
|
||||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attributes: dragAttributes,
|
attributes: dragAttributes,
|
||||||
@@ -61,7 +59,7 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx([style.delay, hasCursor && style.hasCursor, entryCopyId === data.id && style.copyTarget])}
|
className={cx([style.delay, hasCursor && style.hasCursor])}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={dragStyle}
|
style={dragStyle}
|
||||||
data-testid='rundown-delay'
|
data-testid='rundown-delay'
|
||||||
|
|||||||
@@ -58,10 +58,6 @@ $skip-opacity: 0.2;
|
|||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.copyTarget {
|
|
||||||
outline: 2px dashed $block-cursor-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.past:not(.skip) {
|
&.past:not(.skip) {
|
||||||
.timerNote,
|
.timerNote,
|
||||||
.statusElements,
|
.statusElements,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { TbFlagFilled } from 'react-icons/tb';
|
|||||||
|
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
|
||||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||||
@@ -106,7 +105,6 @@ export default function RundownEvent({
|
|||||||
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
|
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
|
||||||
|
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
|
||||||
|
|
||||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||||
|
|
||||||
@@ -248,7 +246,6 @@ export default function RundownEvent({
|
|||||||
playback && style[playback],
|
playback && style[playback],
|
||||||
isSelected && style.selected,
|
isSelected && style.selected,
|
||||||
hasCursor && style.hasCursor,
|
hasCursor && style.hasCursor,
|
||||||
entryCopyId === eventId && style.copyTarget,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleFocusClick = (event: MouseEvent) => {
|
const handleFocusClick = (event: MouseEvent) => {
|
||||||
|
|||||||
@@ -13,10 +13,6 @@
|
|||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.copyTarget {
|
|
||||||
outline: 2px dashed $block-cursor-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.expanded {
|
&.expanded {
|
||||||
margin-block: 0.5rem 0;
|
margin-block: 0.5rem 0;
|
||||||
border-radius: $block-border-radius $block-border-radius 0 0;
|
border-radius: $block-border-radius $block-border-radius 0 0;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import IconButton from '../../../common/components/buttons/IconButton';
|
|||||||
import Tag from '../../../common/components/tag/Tag';
|
import Tag from '../../../common/components/tag/Tag';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
|
||||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { getOffsetState } from '../../../common/utils/offset';
|
import { getOffsetState } from '../../../common/utils/offset';
|
||||||
import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||||
@@ -43,7 +42,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
|
|
||||||
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
|
||||||
|
|
||||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
||||||
{
|
{
|
||||||
@@ -133,7 +131,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
style.group,
|
style.group,
|
||||||
hasCursor && style.hasCursor,
|
hasCursor && style.hasCursor,
|
||||||
!collapsed && style.expanded,
|
!collapsed && style.expanded,
|
||||||
entryCopyId === data.id && style.copyTarget,
|
|
||||||
])}
|
])}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
onClick={handleFocusClick}
|
onClick={handleFocusClick}
|
||||||
|
|||||||
@@ -18,9 +18,6 @@
|
|||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.copyTarget {
|
|
||||||
outline: 2px dashed $block-cursor-color;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.binder {
|
.binder {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Input from '../../../common/components/input/input/Input';
|
|||||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
|
||||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { useEventSelection } from '../useEventSelection';
|
import { useEventSelection } from '../useEventSelection';
|
||||||
@@ -31,7 +30,6 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
|||||||
|
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
||||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
|
||||||
|
|
||||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
||||||
{
|
{
|
||||||
@@ -89,7 +87,6 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
|||||||
className={cx([
|
className={cx([
|
||||||
style.milestone,
|
style.milestone,
|
||||||
hasCursor ? style.hasCursor : null,
|
hasCursor ? style.hasCursor : null,
|
||||||
entryCopyId === entryId ? style.copyTarget : null,
|
|
||||||
])}
|
])}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
onClick={handleFocusClick}
|
onClick={handleFocusClick}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
groupEntries,
|
groupEntries,
|
||||||
initRundown,
|
initRundown,
|
||||||
loadRundown,
|
loadRundown,
|
||||||
|
pasteEntries,
|
||||||
reorderEntry,
|
reorderEntry,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
ungroupEntries,
|
ungroupEntries,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
entryPutValidator,
|
entryPutValidator,
|
||||||
entryReorderValidator,
|
entryReorderValidator,
|
||||||
entrySwapValidator,
|
entrySwapValidator,
|
||||||
|
pastePostValidator,
|
||||||
rundownArrayOfIds,
|
rundownArrayOfIds,
|
||||||
rundownPostValidator,
|
rundownPostValidator,
|
||||||
validateRundownMutation,
|
validateRundownMutation,
|
||||||
@@ -302,6 +304,29 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pastes entries from a source rundown into the active rundown
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/:rundownId/paste',
|
||||||
|
pastePostValidator,
|
||||||
|
validateRundownMutation,
|
||||||
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const rundown = await pasteEntries({
|
||||||
|
entryIds: req.body.entryIds,
|
||||||
|
sourceRundownId: req.body.sourceRundownId,
|
||||||
|
afterId: req.body.afterId,
|
||||||
|
beforeId: req.body.beforeId,
|
||||||
|
});
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a group out of a list of entries
|
* Creates a group out of a list of entries
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -334,6 +334,102 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
|
|||||||
return rundownResult;
|
return rundownResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pastes a list of entries into the active rundown
|
||||||
|
* Supports cross-rundown paste within the same instance
|
||||||
|
* @throws if any entry is not found in the source rundown
|
||||||
|
*/
|
||||||
|
export async function pasteEntries(payload: {
|
||||||
|
entryIds: EntryId[];
|
||||||
|
sourceRundownId: string;
|
||||||
|
afterId?: EntryId;
|
||||||
|
beforeId?: EntryId;
|
||||||
|
}): Promise<Rundown> {
|
||||||
|
const { entryIds, sourceRundownId, afterId, beforeId } = payload;
|
||||||
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
|
|
||||||
|
// resolve source rundown
|
||||||
|
const sourceRundown =
|
||||||
|
sourceRundownId === rundown.id ? rundown : getDataProvider().getRundown(sourceRundownId);
|
||||||
|
|
||||||
|
// validate all entry IDs exist in source
|
||||||
|
for (const entryId of entryIds) {
|
||||||
|
if (!sourceRundown.entries[entryId]) {
|
||||||
|
throw new Error(`Entry with ID ${entryId} not found in source rundown`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve insertion position
|
||||||
|
let currentAfterId: EntryId | undefined;
|
||||||
|
|
||||||
|
if (afterId) {
|
||||||
|
currentAfterId = afterId;
|
||||||
|
} else if (beforeId) {
|
||||||
|
// for "before" positioning, pass it to the first clone and then chain after
|
||||||
|
currentAfterId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEntryIds: EntryId[] = [];
|
||||||
|
|
||||||
|
// sort entry IDs by source rundown flatOrder so paste preserves rundown order
|
||||||
|
// regardless of the order they were selected (e.g. ctrl-click order)
|
||||||
|
const validIds = entryIds.filter((id) => sourceRundown.entries[id]);
|
||||||
|
const orderedIds = validIds.sort((a, b) => {
|
||||||
|
const idxA = sourceRundown.flatOrder.indexOf(a);
|
||||||
|
const idxB = sourceRundown.flatOrder.indexOf(b);
|
||||||
|
return idxA - idxB;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < orderedIds.length; i++) {
|
||||||
|
const entryId = orderedIds[i];
|
||||||
|
const sourceEntry = sourceRundown.entries[entryId];
|
||||||
|
|
||||||
|
// resolve position reference for this entry
|
||||||
|
// when pasting a group and the reference is a child inside another group,
|
||||||
|
// normalise to the parent group so the pasted group lands at the top level
|
||||||
|
const options: InsertOptions = {};
|
||||||
|
if (i === 0 && beforeId && !afterId) {
|
||||||
|
options.before = normaliseGroupPosition(rundown, sourceEntry, beforeId);
|
||||||
|
} else if (currentAfterId) {
|
||||||
|
options.after = normaliseGroupPosition(rundown, sourceEntry, currentAfterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEntry = rundownMutation.clone(rundown, sourceEntry, options);
|
||||||
|
newEntryIds.push(newEntry.id);
|
||||||
|
|
||||||
|
// chain: each new entry becomes the afterId for the next
|
||||||
|
currentAfterId = newEntry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: newEntryIds, external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rundownResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When pasting a group, the position reference must be a top-level entry.
|
||||||
|
* If the reference is a child inside another group, resolve to the parent
|
||||||
|
* so the pasted group ends up at the correct position in rundown.order.
|
||||||
|
*/
|
||||||
|
function normaliseGroupPosition(rundown: Rundown, sourceEntry: OntimeEntry, referenceId: EntryId): EntryId {
|
||||||
|
if (!isOntimeGroup(sourceEntry)) {
|
||||||
|
return referenceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceEntry = rundown.entries[referenceId];
|
||||||
|
if (referenceEntry && 'parent' in referenceEntry && referenceEntry.parent) {
|
||||||
|
return referenceEntry.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return referenceId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clones an entry, ensuring that all dependencies are preserved
|
* Clones an entry, ensuring that all dependencies are preserved
|
||||||
* Handles cloning children if the entry is a group
|
* Handles cloning children if the entry is a group
|
||||||
|
|||||||
@@ -83,4 +83,14 @@ export const rundownArrayOfIds = [
|
|||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const pastePostValidator = [
|
||||||
|
body('entryIds').isArray().notEmpty(),
|
||||||
|
body('entryIds.*').isString(),
|
||||||
|
body('sourceRundownId').isString().notEmpty(),
|
||||||
|
body('afterId').optional().isString(),
|
||||||
|
body('beforeId').optional().isString(),
|
||||||
|
|
||||||
|
requestValidationFunction,
|
||||||
|
];
|
||||||
|
|
||||||
// #endregion operations on rundown entries =======================
|
// #endregion operations on rundown entries =======================
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ test('Copy-paste', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4');
|
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Cut-paste', async ({ page }) => {
|
test('Multi-event copy-paste', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/rundown');
|
await page.goto('http://localhost:4001/rundown');
|
||||||
await page.getByRole('button', { name: 'Edit' }).click();
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
|
||||||
@@ -40,27 +40,39 @@ test('Cut-paste', async ({ page }) => {
|
|||||||
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
|
||||||
// create events
|
// create three events with distinct titles
|
||||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||||
await page.getByTestId('entry-1').getByTestId('entry__title').click();
|
await page.getByTestId('entry-1').getByTestId('entry__title').click();
|
||||||
await page.getByTestId('entry-1').getByTestId('entry__title').fill('first');
|
await page.getByTestId('entry-1').getByTestId('entry__title').fill('alpha');
|
||||||
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
|
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||||
await page.getByTestId('entry-2').getByTestId('entry__title').click();
|
await page.getByTestId('entry-2').getByTestId('entry__title').click();
|
||||||
await page.getByTestId('entry-2').getByTestId('entry__title').fill('second');
|
await page.getByTestId('entry-2').getByTestId('entry__title').fill('beta');
|
||||||
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
|
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
|
||||||
|
|
||||||
// cut first event, paste below second
|
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click();
|
await page.getByTestId('entry-3').getByTestId('entry__title').click();
|
||||||
await page.getByTestId('entry-1').getByTestId('rundown-event').filter({ hasText: '1' }).press('ControlOrMeta+x');
|
await page.getByTestId('entry-3').getByTestId('entry__title').fill('gamma');
|
||||||
await page.getByTestId('entry-2').getByTestId('rundown-event').getByText('2').click();
|
await page.getByTestId('entry-3').getByTestId('entry__title').press('Enter');
|
||||||
await page.getByTestId('entry-2').getByTestId('rundown-event').filter({ hasText: '2' }).press('ControlOrMeta+v');
|
|
||||||
|
|
||||||
// we can verify that the entries have swapped places
|
// multi-select first and third events via ctrl-click
|
||||||
const events = await page.getByTestId('entry__title').all();
|
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
|
||||||
await expect(events[0]).toHaveValue('second');
|
await page.getByTestId('entry-3').getByTestId('rundown-event').click({ modifiers: ['Control'] });
|
||||||
await expect(events[1]).toHaveValue('first');
|
|
||||||
|
// copy and paste after the last event
|
||||||
|
await page.keyboard.press('ControlOrMeta+c');
|
||||||
|
await page.getByTestId('entry-3').getByTestId('rundown-event').click();
|
||||||
|
await page.keyboard.press('ControlOrMeta+v');
|
||||||
|
|
||||||
|
// should now have 5 events: alpha, beta, gamma, alpha-copy, gamma-copy
|
||||||
|
await expect(page.getByTestId('rundown-event')).toHaveCount(5);
|
||||||
|
const titles = await page.getByTestId('entry__title').all();
|
||||||
|
await expect(titles[0]).toHaveValue('alpha');
|
||||||
|
await expect(titles[1]).toHaveValue('beta');
|
||||||
|
await expect(titles[2]).toHaveValue('gamma');
|
||||||
|
await expect(titles[3]).toHaveValue('alpha');
|
||||||
|
await expect(titles[4]).toHaveValue('gamma');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Move', async ({ page }) => {
|
test('Move', async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user