diff --git a/apps/client/src/common/stores/appModeStore.ts b/apps/client/src/common/stores/appModeStore.ts index 5aaa8dc1c..bb893f1d6 100644 --- a/apps/client/src/common/stores/appModeStore.ts +++ b/apps/client/src/common/stores/appModeStore.ts @@ -19,13 +19,10 @@ function persistModeToSession(mode: AppMode) { type AppModeStore = { mode: AppMode; setMode: (mode: AppMode) => void; - cursor: string | null; - setCursor: (cursor: string | null) => void; }; export const useAppMode = create()((set) => ({ mode: getModeFromSession(), - cursor: null, setMode: (mode: AppMode) => { persistModeToSession(mode); @@ -33,5 +30,4 @@ export const useAppMode = create()((set) => ({ return { mode }; }); }, - setCursor: (cursor: string | null) => set({ cursor }), })); diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index b504302a1..c3f0eee53 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -14,6 +14,7 @@ import { cloneEvent } from '../../common/utils/eventsManager'; import QuickAddBlock from './quick-add-block/QuickAddBlock'; import RundownEmpty from './RundownEmpty'; +import { useEventSelection } from './useEventSelection'; import style from './Rundown.module.scss'; @@ -33,7 +34,9 @@ export default function Rundown({ data }: RundownProps) { const { entryCopyId, setEntryCopyId } = useEntryCopy(); // cursor - const { cursor, mode: appMode, setCursor } = useAppMode(); + const { mode: appMode } = useAppMode(); + const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection(); + const cursorRef = useRef(null); const scrollRef = useRef(null); useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run }); @@ -44,34 +47,42 @@ export default function Rundown({ data }: RundownProps) { const deleteAtCursor = useCallback( (cursor: string | null) => { if (!cursor) return; - const previous = getPreviousNormal(rundown, order, cursor).entry?.id ?? null; + const { entry, index } = getPreviousNormal(rundown, order, cursor); deleteEvent([cursor]); - setCursor(previous); + if (entry && index !== null) { + setSelectedEvents({ id: entry.id, selectMode: 'click', index }); + } }, - [deleteEvent, order, rundown, setCursor], + [rundown, order, deleteEvent, setSelectedEvents], ); - const insertAtCursor = useCallback( - (type: SupportedEvent | 'clone', cursor: string | null, above = false) => { - const adjustedCursor = above ? getPreviousNormal(rundown, order, cursor ?? '').entry?.id ?? null : cursor; - - if (adjustedCursor === null) { + const insertCopyAtId = useCallback( + (atId: string | null, copyId: string | null, above = false) => { + const adjustedCursor = above ? getPreviousNormal(rundown, order, atId ?? '').entry?.id ?? null : atId; + if (copyId === null) { // we cant clone without selection - if (type === 'clone') { - return; - } + return; + } + const cloneEntry = rundown[copyId]; + if (cloneEntry?.type === SupportedEvent.Event) { + //if we don't have a cursor add the new event on top + const newEvent = cloneEvent(cloneEntry, adjustedCursor ?? undefined); + addEvent(newEvent); + } + }, + [addEvent, order, rundown], + ); + + const insertAtId = useCallback( + (type: SupportedEvent, id: string | null, above = false) => { + const adjustedCursor = above ? getPreviousNormal(rundown, order, id ?? '').entry?.id ?? null : id; + if (adjustedCursor === null) { // the only thing to do is adding an event at top addEvent({ type }); return; } - if (type === 'clone') { - const cursorEvent = rundown[adjustedCursor]; - if (cursorEvent?.type === SupportedEvent.Event) { - const newEvent = cloneEvent(cursorEvent, cursorEvent.id); - addEvent(newEvent); - } - } else if (type === SupportedEvent.Event) { + if (type === SupportedEvent.Event) { const newEvent = { type: SupportedEvent.Event, }; @@ -92,23 +103,26 @@ export default function Rundown({ data }: RundownProps) { if (order.length < 1) { return; } - let newCursor: string | undefined; + let newCursor: string | null; + let newIndex: number | null; if (cursor === null) { // there is no cursor, we select the first or last depending on direction if it exists - newCursor = direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id; + newCursor = + (direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id) ?? null; + newIndex = direction === 'up' ? order.length : 0; } else { // otherwise we select the next or previous - newCursor = - direction === 'up' - ? getPreviousNormal(rundown, order, cursor).entry?.id - : getNextNormal(rundown, order, cursor).entry?.id; + const selected = + direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor); + newCursor = selected.entry?.id ?? null; + newIndex = selected.index; } - if (newCursor) { - setCursor(newCursor); + if (newCursor && newIndex !== null) { + setSelectedEvents({ id: newCursor, selectMode: 'click', index: newIndex }); } }, - [order, rundown, setCursor], + [order, rundown, setSelectedEvents], ); const moveEntry = useCallback( @@ -134,22 +148,22 @@ export default function Rundown({ data }: RundownProps) { ['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }], ['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }], - ['Escape', () => setCursor(null), { preventDefault: true }], + ['Escape', () => clearSelectedEvents(), { preventDefault: true }], ['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }], - ['alt + E', () => insertAtCursor(SupportedEvent.Event, cursor), { preventDefault: true }], - ['alt + shift + E', () => insertAtCursor(SupportedEvent.Event, cursor, true), { preventDefault: true }], + ['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }], + ['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }], - ['alt + B', () => insertAtCursor(SupportedEvent.Block, cursor), { preventDefault: true }], - ['alt + shift + B', () => insertAtCursor(SupportedEvent.Block, cursor, true), { preventDefault: true }], + ['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }], + ['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }], - ['alt + D', () => insertAtCursor(SupportedEvent.Delay, cursor), { preventDefault: true }], - ['alt + shift + D', () => insertAtCursor(SupportedEvent.Delay, cursor, true), { preventDefault: true }], + ['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }], + ['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }], ['mod + C', () => setEntryCopyId(cursor), { preventDefault: true }], - ['mod + V', () => insertAtCursor('clone', entryCopyId), { preventDefault: true }], - ['mod + shift + V', () => insertAtCursor('clone', entryCopyId, true), { preventDefault: true }], + ['mod + V', () => insertCopyAtId(cursor, entryCopyId), { preventDefault: true }], + ['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }], ['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }], ]); @@ -165,8 +179,9 @@ export default function Rundown({ data }: RundownProps) { if (appMode !== AppMode.Run || !featureData?.selectedEventId) { return; } - setCursor(featureData.selectedEventId); - }, [appMode, featureData.selectedEventId, setCursor]); + const index = order.findIndex((id) => id === featureData.selectedEventId); + setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index }); + }, [appMode, featureData.selectedEventId, order, setSelectedEvents]); const handleOnDragEnd = (event: DragEndEvent) => { const { active, over } = event; @@ -185,7 +200,7 @@ export default function Rundown({ data }: RundownProps) { }; if (statefulEntries.length < 1) { - return insertAtCursor(SupportedEvent.Event, null)} />; + return insertAtId(SupportedEvent.Event, cursor)} />; } let previousStart: MaybeNumber = null; diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index d97faddbb..084a0fac9 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -3,7 +3,6 @@ import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent import { useEventAction } from '../../common/hooks/useEventAction'; import useMemoisedFn from '../../common/hooks/useMemoisedFn'; -import { useAppMode } from '../../common/stores/appModeStore'; import { useEmitLog } from '../../common/stores/logger'; import { cloneEvent } from '../../common/utils/eventsManager'; @@ -56,22 +55,15 @@ export default function RundownEntry(props: RundownEntryProps) { } = props; const { emitError } = useEmitLog(); const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); - const cursor = useAppMode((state) => state.cursor); - const setCursor = useAppMode((state) => state.setCursor); const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); const removeOpenEvent = useCallback(() => { unselect(data.id); - // clear cursor if we are deleting the event that is currently selected - if (cursor === data.id) { - setCursor(null); - } - }, [unselect, data.id, cursor, setCursor]); + }, [unselect, data.id]); const clearMultiSelection = useCallback(() => { clearSelectedEvents(); - setCursor(null); - }, [clearSelectedEvents, setCursor]); + }, [clearSelectedEvents]); // Create / delete new events type FieldValue = { diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 74d24597b..c26ac7364 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -11,7 +11,6 @@ import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; -import { useAppMode } from '../../../common/stores/appModeStore'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import type { EventItemActions } from '../RundownEntry'; import { useEventIdSwapping } from '../useEventIdSwapping'; @@ -88,7 +87,6 @@ export default function EventBlock(props: EventBlockProps) { } = props; const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); const { selectedEvents, setSelectedEvents } = useEventSelection(); - const setCursor = useAppMode((state) => state.setCursor); const handleRef = useRef(null); const [isVisible, setIsVisible] = useState(false); @@ -231,7 +229,6 @@ export default function EventBlock(props: EventBlockProps) { const index = eventIndex - 1; const editMode = getSelectionMode(event); setSelectedEvents({ id: eventId, index, selectMode: editMode }); - setCursor(eventId); }; return ( diff --git a/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx b/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx index 0dcdd4fe5..e48234b34 100644 --- a/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx +++ b/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx @@ -17,7 +17,6 @@ import { useEventSelection } from '../useEventSelection'; export default function RundownMenu() { const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); - const setCursor = useAppMode((state) => state.setCursor); const appMode = useAppMode((state) => state.mode); const { deleteAllEvents } = useEventAction(); @@ -27,9 +26,8 @@ export default function RundownMenu() { const deleteAll = useCallback(() => { deleteAllEvents(); clearSelectedEvents(); - setCursor(null); onClose(); - }, [clearSelectedEvents, deleteAllEvents, onClose, setCursor]); + }, [clearSelectedEvents, deleteAllEvents, onClose]); return ( <> diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index c58d291cc..8602a859c 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -1,5 +1,5 @@ import { MouseEvent } from 'react'; -import { isOntimeEvent, OntimeEvent, RundownCached } from 'ontime-types'; +import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, RundownCached } from 'ontime-types'; import { create } from 'zustand'; import { RUNDOWN } from '../../common/api/constants'; @@ -10,7 +10,8 @@ export type SelectionMode = 'shift' | 'click' | 'ctrl'; interface EventSelectionStore { selectedEvents: Set; - anchoredIndex: number | null; + anchoredIndex: MaybeNumber; + cursor: MaybeString; setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void; clearSelectedEvents: () => void; clearMultiSelect: () => void; @@ -20,13 +21,14 @@ interface EventSelectionStore { export const useEventSelection = create()((set, get) => ({ selectedEvents: new Set(), anchoredIndex: null, + cursor: null, setSelectedEvents: (selectionArgs) => { const { id, index, selectMode } = selectionArgs; const { selectedEvents, anchoredIndex } = get(); // on click, we replace selection with event if (selectMode === 'click') { - return set({ selectedEvents: new Set([id]), anchoredIndex: index }); + return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id }); } // on ctrl + click, we toggle the selection of that event @@ -39,6 +41,7 @@ export const useEventSelection = create()((set, get) => ({ return set({ selectedEvents: selectedEvents.add(id), anchoredIndex: index, + cursor: id, }); } @@ -83,7 +86,7 @@ export const useEventSelection = create()((set, get) => ({ }); } }, - clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null }), + clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null }), clearMultiSelect: () => { const { selectedEvents } = get(); const [firstSelected] = selectedEvents; diff --git a/e2e/tests/features/209-rundown-shortcuts.spec.ts b/e2e/tests/features/209-rundown-shortcuts.spec.ts new file mode 100644 index 000000000..1c69921c0 --- /dev/null +++ b/e2e/tests/features/209-rundown-shortcuts.spec.ts @@ -0,0 +1,144 @@ +import { test, expect } from '@playwright/test'; + +test('Copy Past', async ({ page }) => { + await page.goto('http://localhost:4001/rundown'); + + // clear rundown + await page.getByRole('button', { name: 'Clear rundown' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + + //create event + await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByTestId('entry-1').click(); + await page.getByLabel('Cue', { exact: true }).click(); + await page.getByLabel('Cue', { exact: true }).fill('4'); + await page.getByLabel('Cue', { exact: true }).press('Enter'); + await page.getByTestId('entry-1').click(); + await page.getByTestId('block__title').click(); + await page.getByTestId('block__title').fill('test'); + await page.getByTestId('block__title').press('Enter'); + + //copy past below + await page.locator('div').filter({ hasText: /^4$/ }).click(); + await page.locator('div').filter({ hasText: /^4$/ }).press('Control+c'); + await page.locator('div').filter({ hasText: /^4$/ }).press('Control+v'); + + //assert + await expect(page.getByTestId('entry-2')).toBeVisible(); + await expect(page.getByTestId('entry-2').getByTestId('block__title')).toHaveValue('test'); + await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('5'); + + //copy past above + await page.locator('div').filter({ hasText: /^5$/ }).click(); + await page.locator('div').filter({ hasText: /^5$/ }).press('Control+c'); + await page.locator('div').filter({ hasText: /^5$/ }).press('Control+Shift+v'); + + //assert + await expect(page.getByTestId('entry-2')).toBeVisible(); + await expect(page.getByTestId('entry-2').getByTestId('block__title')).toHaveValue('test'); + await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('4.1'); +}); + +test('Move', async ({ page }) => { + await page.goto('http://localhost:4001/rundown'); + + // clear rundown + await page.getByRole('button', { name: 'Clear rundown' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + + //create events + await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByRole('button', { name: 'Event' }).nth(4).click(); + await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); + + //copy move down + await page.getByTestId('entry-1').locator('#event-block').getByText('1').click(); + await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Control+ArrowDown'); + await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('1'); + + //copy move up + await page.getByTestId('entry-3').locator('#event-block').getByText('3').click(); + await page.getByTestId('entry-3').locator('#event-block div').filter({ hasText: '3' }).press('Alt+Control+ArrowUp'); + await page.getByTestId('entry-2').locator('div').filter({ hasText: /^3$/ }).press('Alt+Control+ArrowUp'); + await expect(page.getByTestId('entry-1').locator('#event-block')).toContainText('3'); +}); + +test('Add block', async ({ page }) => { + await page.goto('http://localhost:4001/rundown'); + + // clear rundown + await page.getByRole('button', { name: 'Clear rundown' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + + //create events + await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByTestId('entry-1').click(); + await page.getByTestId('block__title').press('Escape'); + + //add block below + await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+B'); + await expect(page.getByPlaceholder('Block title')).toBeVisible(); + + //add block above + await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Shift+B'); + await expect(page.getByTestId('entry-0').getByTestId('block__title')).toBeVisible(); +}); + +test('Add delay', async ({ page }) => { + await page.goto('http://localhost:4001/rundown'); + + // clear rundown + await page.getByRole('button', { name: 'Clear rundown' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + + //create events + await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByTestId('entry-1').click(); + await page.getByTestId('block__title').press('Escape'); + + //add delay below + await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+D'); + await expect(page.getByTestId('delay-input')).toBeVisible(); + + //add delay above + await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Shift+D'); + await expect(page.getByTestId('entry-0').getByTestId('delay-input')).toBeVisible(); +}); + +test('Add event', async ({ page }) => { + await page.goto('http://localhost:4001/rundown'); + + // clear rundown + await page.getByRole('button', { name: 'Clear rundown' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + + //create events + await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByTestId('entry-1').click(); + await page.getByTestId('block__title').press('Escape'); + + //add event below + await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+E'); + await expect(page.getByTestId('entry-2').locator('#event-block').getByText('2')).toBeVisible(); + + //add event above + await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Shift+E'); + await expect(page.getByTestId('entry-1').locator('#event-block')).toContainText('0.1'); +}); + +test('Delete event', async ({ page }) => { + await page.goto('http://localhost:4001/rundown'); + + // clear rundown + await page.goto('http://localhost:4001/rundown'); + await page.getByRole('button', { name: 'Clear rundown' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + + //create event + await page.getByRole('button', { name: 'Create Event' }).click(); + + //delete event + await page.locator('#event-block div').filter({ hasText: '1' }).click(); + await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Backspace'); + await expect(page.getByRole('button', { name: 'Create Event' })).toBeVisible(); +});