From 23855da0564b575007ca7d755b64a19b466938c9 Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 27 Oct 2023 19:39:28 +0200 Subject: [PATCH 1/6] fix: undo/redo and object order after removal --- apps/app/src/electron/EverythingService.ts | 170 +++++++++++++++----- apps/app/src/electron/SuperConductor.ts | 4 +- apps/app/src/electron/api/ApiServer.ts | 12 ++ apps/app/src/electron/api/ProjectService.ts | 12 ++ apps/app/src/ipc/IPCAPI.ts | 32 ++-- apps/app/src/lib/__tests__/util.test.ts | 37 ++++- apps/app/src/lib/util.ts | 44 ++++- apps/app/src/react/App.tsx | 22 ++- apps/app/src/react/api/ApiClient.ts | 18 ++- tsconfig.build.json | 2 +- 10 files changed, 281 insertions(+), 72 deletions(-) diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index 73c509c2..e35d8cf7 100644 --- a/apps/app/src/electron/EverythingService.ts +++ b/apps/app/src/electron/EverythingService.ts @@ -26,7 +26,6 @@ import { listAvailableDeviceIDs, MoveTarget, shortID, - unReplaceUndefined, updateGroupPlayingParts, } from '../lib/util' import { PartialDeep } from 'type-fest' @@ -129,6 +128,32 @@ type ConvertToServerSide = { : T[K] } +// const unreplaceUndefined = async (context: HookContext, next: NextFunction) => { +// context.arguments = unReplaceUndefined(context.arguments) +// await next() +// } + +// const undoable = async (context: HookContext, next: NextFunction) => { +// await next() +// if (context.self && isUndoable(context.result)) { +// context.self.registerUndoable( +// context.arguments, +// (context.self as any)[context.method ?? '']?.bind(context.self), +// context.result +// ) +// } +// } + +function Undoable(target: EverythingService, _key: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value + descriptor.value = async function (...args: any) { + const result = await originalMethod.apply(this, args) + target.registerUndoable.call(this, args, originalMethod.bind(this), result) + return result + } + return descriptor +} + /** * This class is used server-side, to handle requests from the client * The methods in here will later be moved away to other Services @@ -142,7 +167,6 @@ export class EverythingService private undoPointer: UndoPointer = -1 constructor( - ipcMain: Electron.IpcMain, private _log: LoggerLike, private _renderLog: LoggerLike, private storage: StorageHandler, @@ -166,44 +190,63 @@ export class EverythingService for (const methodName of Object.getOwnPropertyNames(EverythingService.prototype)) { if (methodName[0] !== '_') { const fcn = (this as any)[methodName].bind(this) - if (fcn) { - ipcMain.handle(methodName, async (event, args0: string[]) => { - try { - const args = unReplaceUndefined(args0) - const result = await fcn(...args) - if (isUndoable(result)) { - // Clear any future things in the undo ledger: - this.undoLedger.splice(this.undoPointer + 1, this.undoLedger.length) - // Add the new action to the undo ledger: - this.undoLedger.push({ - description: result.description, - arguments: args, - undo: result.undo, - redo: fcn, - }) - if (this.undoLedger.length > MAX_UNDO_LEDGER_LENGTH) { - this.undoLedger.splice(0, this.undoLedger.length - MAX_UNDO_LEDGER_LENGTH) - } - this.undoPointer = this.undoLedger.length - 1 - this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) - - // string represents "anything but undefined" here: - return (result as UndoableResult).result - } else { - return result - } - } catch (error) { - this.callbacks.handleError( - `Error when calling ${methodName}: ${error}`, - typeof error === 'object' && (error as any).stack - ) - throw error - } - }) + if (typeof fcn === 'function') { + // Monkey-patching methods in this class. There are definitely better ways to do it... + // ;(this as any)[methodName] = async (...args0: string[]) => { + // try { + // const args = unReplaceUndefined(args0) + // let result = fcn(...args) + // if (result instanceof Promise) result = await result + // if (isUndoable(result)) { + // // Clear any future things in the undo ledger: + // this.undoLedger.splice(this.undoPointer + 1, this.undoLedger.length) + // // Add the new action to the undo ledger: + // this.undoLedger.push({ + // description: result.description, + // arguments: args, + // undo: result.undo, + // redo: fcn, + // }) + // if (this.undoLedger.length > MAX_UNDO_LEDGER_LENGTH) { + // this.undoLedger.splice(0, this.undoLedger.length - MAX_UNDO_LEDGER_LENGTH) + // } + // this.undoPointer = this.undoLedger.length - 1 + // this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) + // // string represents "anything but undefined" here: + // return (result as UndoableResult).result + // } else { + // return result + // } + // } catch (error) { + // this.callbacks.handleError( + // `Error when calling ${methodName}: ${error}`, + // typeof error === 'object' && (error as any).stack + // ) + // throw error + // } + // } } } } } + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + public registerUndoable(args: any, fcn: () => any, result: UndoableResult): void { + this.undoLedger.splice(this.undoPointer + 1, this.undoLedger.length) + // Add the new action to the undo ledger: + this.undoLedger.push({ + description: result.description, + arguments: args, + undo: result.undo, + redo: fcn, + }) + if (this.undoLedger.length > MAX_UNDO_LEDGER_LENGTH) { + this.undoLedger.splice(0, this.undoLedger.length - MAX_UNDO_LEDGER_LENGTH) + } + this.undoPointer = this.undoLedger.length - 1 + this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) + } + public getProject(): Project { return this.storage.getProject() } @@ -607,6 +650,7 @@ export class EverythingService await this.playPart({ rundownId: arg.rundownId, groupId: arg.groupId, partId: prevPart.id }) } } + @Undoable async newPart(arg: { rundownId: string /** The group to create the part into. If null; will create a "transparent group" */ @@ -669,6 +713,7 @@ export class EverythingService result, } } + @Undoable async insertParts(arg: { rundownId: string groupId: string | null @@ -774,6 +819,7 @@ export class EverythingService }) } } + @Undoable private async _insertPartsAsTransparentGroup(arg: { rundownId: string parts: { part: Part; resources: ResourceAny[] }[] @@ -827,6 +873,7 @@ export class EverythingService result: inserted, } } + @Undoable async updatePart(arg: { rundownId: string groupId: string @@ -870,6 +917,7 @@ export class EverythingService description: ActionDescription.UpdatePart, } } + @Undoable async upsertPart(arg: { rundownId: string groupId: string @@ -931,6 +979,7 @@ export class EverythingService description: ActionDescription.UpsertPart, } } + @Undoable async upsertPartByExternalId(arg: { rundownId: string groupId: string @@ -952,6 +1001,7 @@ export class EverythingService part: arg.part, }) } + @Undoable async newGroup(arg: { rundownId: string; name: string }): Promise> { const newGroup: Group = { ...getDefaultGroup(), @@ -973,6 +1023,7 @@ export class EverythingService result: newGroup.id, } } + @Undoable async insertGroups(arg: { rundownId: string groups: { @@ -1064,6 +1115,7 @@ export class EverythingService result: inserted, } } + @Undoable async updateGroup(arg: { rundownId: string groupId: string @@ -1113,6 +1165,7 @@ export class EverythingService description: ActionDescription.UpdateGroup, } } + @Undoable async upsertGroup(arg: { rundownId: string groupId: string | undefined @@ -1202,6 +1255,7 @@ export class EverythingService description: ActionDescription.UpsertGroup, } } + @Undoable async upsertGroupByExternalId(arg: { rundownId: string externalId: string @@ -1222,6 +1276,7 @@ export class EverythingService useExternalIdForParts: true, }) } + @Undoable async deletePart(arg: { rundownId: string groupId: string @@ -1266,6 +1321,7 @@ export class EverythingService description: ActionDescription.DeletePart, } } + @Undoable async deleteGroup(arg: { rundownId: string; groupId: string }): Promise | undefined> { const { rundown, group } = this.getGroup(arg) @@ -1296,6 +1352,7 @@ export class EverythingService description: ActionDescription.DeleteGroup, } } + @Undoable async moveParts(arg: { parts: { rundownId: string; partId: string }[] to: { rundownId: string; groupId: string | null; target: MoveTarget } @@ -1481,6 +1538,7 @@ export class EverythingService result: resultingParts, } } + @Undoable async duplicatePart(arg: { rundownId: string; groupId: string; partId: string }): Promise> { const { rundown, group, part } = this.getPart(arg) @@ -1530,6 +1588,7 @@ export class EverythingService description: ActionDescription.DuplicatePart, } } + @Undoable async moveGroups(arg: { rundownId: string groupIds: string[] @@ -1573,6 +1632,7 @@ export class EverythingService description: ActionDescription.MoveGroup, } } + @Undoable async duplicateGroup(arg: { rundownId: string; groupId: string }): Promise> { const { rundown, group } = this.getGroup(arg) @@ -1599,6 +1659,7 @@ export class EverythingService } } + @Undoable async updateTimelineObj(arg: { rundownId: string groupId: string @@ -1638,6 +1699,8 @@ export class EverythingService description: ActionDescription.UpdateTimelineObj, } } + + @Undoable async deleteTimelineObj(arg: { rundownId: string groupId: string @@ -1648,7 +1711,7 @@ export class EverythingService const result = findTimelineObjInRundown(rundown, arg.timelineObjId) if (!result) throw new Error(`TimelineObj ${arg.timelineObjId} not found.`) - const { group, part, timelineObj } = result + const { group, part } = result const groupId = group.id const partId = part.id @@ -1656,10 +1719,13 @@ export class EverythingService return } - const timelineObjIndex = findTimelineObjIndex(part, arg.timelineObjId) - const modified = deleteTimelineObj(part, arg.timelineObjId) + const originalPartTimeline = part.timeline + const modifiedPartTimeline = deleteTimelineObj(originalPartTimeline, arg.timelineObjId) - if (modified) postProcessPart(part) + if (modifiedPartTimeline !== originalPartTimeline) { + part.timeline = modifiedPartTimeline + postProcessPart(part) + } if (part.timeline.length <= 0) this.stopPart({ rundownId: arg.rundownId, groupId, partId }).catch(this._log.error) this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) @@ -1668,14 +1734,15 @@ export class EverythingService undo: () => { const { rundown, group, part } = this.getPart({ rundownId: arg.rundownId, groupId, partId }) - // Re-insert the timelineObj in its original position. - part.timeline.splice(timelineObjIndex, 0, timelineObj) + // Replace with the original timeline. + part.timeline = originalPartTimeline postProcessPart(part) this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.DeleteTimelineObj, } } + @Undoable async insertTimelineObjs(arg: { rundownId: string groupId: string @@ -1801,6 +1868,7 @@ export class EverythingService result: inserted, } } + @Undoable async moveTimelineObjToNewLayer(arg: { rundownId: string groupId: string @@ -1855,6 +1923,7 @@ export class EverythingService } } + @Undoable async addResourcesToTimeline(arg: { rundownId: string groupId: string @@ -1976,6 +2045,7 @@ export class EverythingService description: ActionDescription.addResourcesToTimeline, } } + @Undoable async toggleGroupLoop(arg: { rundownId: string groupId: string @@ -2006,6 +2076,7 @@ export class EverythingService description: ActionDescription.ToggleGroupLoop, } } + @Undoable async toggleGroupAutoplay(arg: { rundownId: string groupId: string @@ -2036,6 +2107,7 @@ export class EverythingService description: ActionDescription.ToggleGroupAutoplay, } } + @Undoable async toggleGroupOneAtATime(arg: { rundownId: string groupId: string @@ -2077,6 +2149,7 @@ export class EverythingService description: ActionDescription.toggleGroupOneAtATime, } } + @Undoable async toggleGroupDisable(arg: { rundownId: string groupId: string @@ -2107,6 +2180,7 @@ export class EverythingService description: ActionDescription.ToggleGroupDisable, } } + @Undoable async toggleGroupLock(arg: { rundownId: string; groupId: string; value: boolean }): Promise> { const { rundown, group } = this.getGroup(arg) const originalValue = group.locked @@ -2126,6 +2200,7 @@ export class EverythingService description: ActionDescription.ToggleGroupLock, } } + @Undoable async toggleGroupCollapse(arg: { rundownId: string groupId: string @@ -2149,6 +2224,7 @@ export class EverythingService description: ActionDescription.ToggleGroupCollapse, } } + @Undoable async toggleAllGroupsCollapse(arg: { rundownId: string; value: boolean }): Promise> { const { rundown } = this.getRundown(arg) @@ -2216,6 +2292,7 @@ export class EverythingService return this.storage.getProject() } + @Undoable async newRundown(arg: { name: string }): Promise> { const rundown = this.storage.newRundown(arg.name) const fileName = rundown.name @@ -2247,6 +2324,7 @@ export class EverythingService // Note: This is not undoable } + @Undoable async openRundown(arg: { rundownId: string }): Promise> { this.storage.openRundown(arg.rundownId) this._saveUpdates({}) @@ -2259,6 +2337,7 @@ export class EverythingService description: ActionDescription.OpenRundown, } } + @Undoable async closeRundown(arg: { rundownId: string }): Promise> { const { rundown } = this.getRundown(arg) if (!rundown) { @@ -2282,6 +2361,7 @@ export class EverythingService } } + @Undoable async renameRundown(arg: { rundownId: string; newName: string }): Promise> { const rundown = this.storage.getRundown(arg.rundownId) if (!rundown) { @@ -2326,6 +2406,7 @@ export class EverythingService const playData = getGroupPlayData(group.preparedPlayData) return Boolean(playData.playheads[part.id]) } + @Undoable async createMissingMapping(arg: { rundownId: string; mappingId: string }): Promise> { const project = this.getProject() const rundown = this.storage.getRundown(arg.rundownId) @@ -2411,6 +2492,7 @@ export class EverythingService } } + @Undoable async addPeripheralArea(arg: { bridgeId: BridgeId; deviceId: PeripheralId }): Promise> { const bridgeIdStr = unprotectString(arg.bridgeId) const deviceIdStr = unprotectString(arg.deviceId) @@ -2456,6 +2538,7 @@ export class EverythingService description: ActionDescription.AddPeripheralArea, } } + @Undoable async removePeripheralArea(data: { bridgeId: BridgeId deviceId: PeripheralId @@ -2498,6 +2581,7 @@ export class EverythingService description: ActionDescription.RemovePeripheralArea, } } + @Undoable async updatePeripheralArea(arg: { bridgeId: BridgeId deviceId: PeripheralId @@ -2545,6 +2629,7 @@ export class EverythingService description: ActionDescription.UpdatePeripheralArea, } } + @Undoable async assignAreaToGroup(arg: { groupId: string | undefined areaId: string @@ -2608,6 +2693,7 @@ export class EverythingService async finishDefiningArea(): Promise { this._saveUpdates({ definingArea: null }) } + @Undoable async setApplicationTrigger(arg: { triggerAction: ApplicationTrigger['action'] trigger: ApplicationTrigger | null diff --git a/apps/app/src/electron/SuperConductor.ts b/apps/app/src/electron/SuperConductor.ts index 360f4b30..e13866eb 100644 --- a/apps/app/src/electron/SuperConductor.ts +++ b/apps/app/src/electron/SuperConductor.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, dialog, ipcMain } from 'electron' +import { BrowserWindow, dialog } from 'electron' import { autoUpdater } from 'electron-updater' import { AutoFillMode, Group } from '../models/rundown/Group' import { EverythingService } from './EverythingService' @@ -225,7 +225,7 @@ export class SuperConductor { }, }) - this.ipcServer = new EverythingService(ipcMain, this.log, this.renderLog, this.storage, this, this.session, { + this.ipcServer = new EverythingService(this.log, this.renderLog, this.storage, this, this.session, { refreshResources: () => { this.refreshResources() }, diff --git a/apps/app/src/electron/api/ApiServer.ts b/apps/app/src/electron/api/ApiServer.ts index 21c1a0da..d70893cc 100644 --- a/apps/app/src/electron/api/ApiServer.ts +++ b/apps/app/src/electron/api/ApiServer.ts @@ -14,6 +14,7 @@ import { ClientMethods, ProjectsEvents, RundownsEvents, ServiceName, ServiceType import { Project, ProjectBase } from '../../models/project/Project' import { PartService } from './PartService' import { GroupService } from './GroupService' +import { unReplaceUndefined } from '../../lib/util' export class ApiServer { private app = koa(feathers()) @@ -61,6 +62,17 @@ export class ApiServer { events: [], }) + // TODO: potentially may break some thing in ultra rare cases. Should we enable it only for selected methods? + this.app.hooks({ + before: { + all: [ + async (context: HookContext) => { + context.data = unReplaceUndefined(context.data) + }, + ], + }, + }) + this.app.service(ServiceName.RUNDOWNS).publish((data: Rundown, _context: HookContext) => { return this.app.channel(RUNDOWN_CHANNEL_PREFIX + data.id) }) diff --git a/apps/app/src/electron/api/ProjectService.ts b/apps/app/src/electron/api/ProjectService.ts index 6649a9d8..9f23ab78 100644 --- a/apps/app/src/electron/api/ProjectService.ts +++ b/apps/app/src/electron/api/ProjectService.ts @@ -65,4 +65,16 @@ export class ProjectService extends EventEmitter { // TODO: access control return await this.everythingService.importProject() } + + async undo(): Promise { + // TODO: likely not the best place for this method + // TODO: access control + return await this.everythingService.undo() + } + + async redo(): Promise { + // TODO: likely not the best place for this method + // TODO: access control + return await this.everythingService.redo() + } } diff --git a/apps/app/src/ipc/IPCAPI.ts b/apps/app/src/ipc/IPCAPI.ts index fb2f7658..22f8c8bd 100644 --- a/apps/app/src/ipc/IPCAPI.ts +++ b/apps/app/src/ipc/IPCAPI.ts @@ -51,6 +51,7 @@ type KeyArrays = { type ServiceKeyArrays = KeyArrays // TODO: this is temporary; Use decorators or something +// those are the arrays of service methods exposed to the clients export const ClientMethods: ServiceKeyArrays = { [ServiceName.GROUPS]: [ 'create', @@ -82,7 +83,18 @@ export const ClientMethods: ServiceKeyArrays = { 'deleteTimelineObj', 'insertTimelineObjs', ], - [ServiceName.PROJECTS]: ['get', 'create', 'update', 'getAll', 'open', 'import', 'export', 'unsubscribe'], + [ServiceName.PROJECTS]: [ + 'get', + 'create', + 'update', + 'getAll', + 'open', + 'import', + 'export', + 'unsubscribe', + 'undo', + 'redo', + ], [ServiceName.REPORTING]: [ 'log', 'handleClientError', @@ -99,26 +111,8 @@ export const ClientMethods: ServiceKeyArrays = { 'isPlaying', 'close', 'open', - // 'pauseGroup', - // 'playGroup', - // 'playNext', - // 'playPrev', - // 'stopGroup', 'updateTimelineObj', 'moveTimelineObjToNewLayer', - - // 'newPart', - // 'insertParts', - // 'updatePart', - // 'newGroup', - // 'insertGroups', - // 'updateGroup', - // 'deletePart', - // 'deleteGroup', - // 'moveParts', - // 'duplicatePart', - // 'moveGroups', - // 'duplicateGroup', ], } diff --git a/apps/app/src/lib/__tests__/util.test.ts b/apps/app/src/lib/__tests__/util.test.ts index fc83112b..c951a2d4 100644 --- a/apps/app/src/lib/__tests__/util.test.ts +++ b/apps/app/src/lib/__tests__/util.test.ts @@ -1,4 +1,5 @@ -import { deepExtendRemovingUndefined } from '../util' +import { TimelineObj } from '../../models/rundown/TimelineObj' +import { deepExtendRemovingUndefined, deleteTimelineObj } from '../util' test('deepExtendRemovingUndefined', () => { { @@ -85,3 +86,37 @@ test('deepExtendRemovingUndefined', () => { }) } }) + +function makeTestObject(id: string, start: string | number): TimelineObj { + return { + obj: { + content: {} as any, + enable: { + start, + }, + id, + layer: '', + }, + resolved: {} as any + } +} + +describe('deleteTimelineObj', () => { + it('returns the same array if no changes', () => { + const timeline = [makeTestObject('obj1', '#obj2.end + 500'), makeTestObject('obj2', 1000)] + const result = deleteTimelineObj(timeline, 'obj3') + expect(result).toBe(timeline) + }) + + it('patches dependent objects', () => { + const timeline = [makeTestObject('obj1', '#obj2.end + 500'), makeTestObject('obj2', 1000)] + const result = deleteTimelineObj([makeTestObject('obj1', '#obj2.end + 500'), makeTestObject('obj2', 1000)], 'obj2') + expect(result).toEqual([makeTestObject('obj1', '1000 + 500')]) // not great, but acceptable + }) + + it('patches dependent objects 2', () => { + const timeline = [makeTestObject('obj1', '#obj2.end + 500'), makeTestObject('obj2', '#obj3.end'), makeTestObject('obj3', 0)] + const result = deleteTimelineObj(timeline, 'obj2') + expect(result).toEqual([makeTestObject('obj1', '#obj3.end + 500'), makeTestObject('obj3', 0)]) + }) +}) diff --git a/apps/app/src/lib/util.ts b/apps/app/src/lib/util.ts index a94c5dce..9e68487e 100644 --- a/apps/app/src/lib/util.ts +++ b/apps/app/src/lib/util.ts @@ -119,12 +119,46 @@ export const deletePart = (group: Group, partId: string): Part | undefined => { } return deletedPart } -export const deleteTimelineObj = (part: Part, timelineObjId: string): boolean => { - if (part.timeline.find((t) => t.obj.id === timelineObjId)) { - part.timeline = part.timeline.filter((t) => t.obj.id !== timelineObjId) - return true +export const deleteTimelineObj = (timeline: TimelineObj[], timelineObjId: string): TimelineObj[] => { + const foundObj = timeline.find((t) => t.obj.id === timelineObjId) + if (!foundObj) return timeline + const idRegexEnd = new RegExp(`#${timelineObjId}.end`, 'g') + const firstEnable = Array.isArray(foundObj.obj.enable) ? foundObj.obj.enable[0] : foundObj.obj.enable + const start = firstEnable?.start + if (typeof start === 'string' || typeof start === 'number') { + timeline = timeline.map((tObj) => { + const enableArray = Array.isArray(tObj.obj.enable) ? tObj.obj.enable : [tObj.obj.enable] + for (const enable of enableArray) { + if (enable.start && typeof enable.start === 'string') { + // TODO: immer js? + tObj = { + ...tObj, + obj: { + ...tObj.obj, + enable: { + ...enable, + start: enable.start.replace(idRegexEnd, start.toString()), + }, + }, + } + } + if (enable.end && typeof enable.end === 'string') { + tObj = { + ...tObj, + obj: { + ...tObj.obj, + enable: { + ...enable, + end: enable.end.replace(idRegexEnd, start.toString()), + }, + }, + } + } + } + return tObj + }) } - return false + return timeline.filter((t) => t.obj.id !== timelineObjId) } export function getResolvedTimelineTotalDuration(resolvedTimeline: ResolvedTimeline, filterInfinites: true): number diff --git a/apps/app/src/react/App.tsx b/apps/app/src/react/App.tsx index 1d23b193..65580273 100644 --- a/apps/app/src/react/App.tsx +++ b/apps/app/src/react/App.tsx @@ -519,16 +519,36 @@ export const App = observer(function App() { handleError(error) } } + function onUndo(): void { + setUserAgreementScreenOpen(false) + // eslint-disable-next-line @typescript-eslint/unbound-method + serverAPI.undo().catch(handleError) + } + function onRedo(): void { + setUserAgreementScreenOpen(false) + // eslint-disable-next-line @typescript-eslint/unbound-method + serverAPI.redo().catch(handleError) + } sorensen.bind('Escape', onEscapeKey, { up: false, global: true, exclusive: true, preventDefaultPartials: false, }) + sorensen.bind('Control+KeyZ', onUndo, { + up: false, + global: true, + }) + sorensen.bind('Control+KeyY', onRedo, { + up: false, + global: true, + }) return () => { sorensen.unbind('Escape', onEscapeKey) + sorensen.unbind('Control+KeyZ', onUndo) + sorensen.unbind('Control+KeyY', onRedo) } - }, [sorensenInitialized, handleError, gui, currentRundownId]) + }, [sorensenInitialized, handleError, gui, currentRundownId, serverAPI]) useMemoComputedValue(() => { if (!project) return diff --git a/apps/app/src/react/api/ApiClient.ts b/apps/app/src/react/api/ApiClient.ts index 194e203b..f44b449f 100644 --- a/apps/app/src/react/api/ApiClient.ts +++ b/apps/app/src/react/api/ApiClient.ts @@ -1,7 +1,7 @@ import { ClientMethods, IPCServerMethods, ServiceName, ServiceTypes } from '../../ipc/IPCAPI' import { replaceUndefined } from '../../lib/util' -import { feathers } from '@feathersjs/feathers' +import { HookContext, feathers } from '@feathersjs/feathers' import socketio, { SocketService } from '@feathersjs/socketio-client' import io from 'socket.io-client' import { Rundown } from '../../models/rundown/Rundown' @@ -61,6 +61,16 @@ app.use( } ) +app.hooks({ + before: { + all: [ + async (context: HookContext) => { + context.data = replaceUndefined(context.data) + }, + ], + }, +}) + type ServerArgs = Parameters type ServerReturn = Promise> @@ -320,4 +330,10 @@ export class ApiClient { async setApplicationTrigger(...args: ServerArgs<'setApplicationTrigger'>): ServerReturn<'setApplicationTrigger'> { return this.invokeServerMethod('setApplicationTrigger', ...args) } + async undo(): Promise { + return this.projectService.undo() + } + async redo(): Promise { + return this.projectService.redo() + } } diff --git a/tsconfig.build.json b/tsconfig.build.json index b1ec65ce..b546f770 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -17,7 +17,7 @@ "target": "es6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, // "lib": ["es6"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ From 61ab14287c30710f16a6551d6de109abeb80e301 Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 27 Oct 2023 19:43:35 +0200 Subject: [PATCH 2/6] chore: remove commented out code --- apps/app/src/electron/EverythingService.ts | 57 ---------------------- 1 file changed, 57 deletions(-) diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index e35d8cf7..7bc35d6a 100644 --- a/apps/app/src/electron/EverythingService.ts +++ b/apps/app/src/electron/EverythingService.ts @@ -128,22 +128,6 @@ type ConvertToServerSide = { : T[K] } -// const unreplaceUndefined = async (context: HookContext, next: NextFunction) => { -// context.arguments = unReplaceUndefined(context.arguments) -// await next() -// } - -// const undoable = async (context: HookContext, next: NextFunction) => { -// await next() -// if (context.self && isUndoable(context.result)) { -// context.self.registerUndoable( -// context.arguments, -// (context.self as any)[context.method ?? '']?.bind(context.self), -// context.result -// ) -// } -// } - function Undoable(target: EverythingService, _key: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value descriptor.value = async function (...args: any) { @@ -187,47 +171,6 @@ export class EverythingService } ) { super() - for (const methodName of Object.getOwnPropertyNames(EverythingService.prototype)) { - if (methodName[0] !== '_') { - const fcn = (this as any)[methodName].bind(this) - if (typeof fcn === 'function') { - // Monkey-patching methods in this class. There are definitely better ways to do it... - // ;(this as any)[methodName] = async (...args0: string[]) => { - // try { - // const args = unReplaceUndefined(args0) - // let result = fcn(...args) - // if (result instanceof Promise) result = await result - // if (isUndoable(result)) { - // // Clear any future things in the undo ledger: - // this.undoLedger.splice(this.undoPointer + 1, this.undoLedger.length) - // // Add the new action to the undo ledger: - // this.undoLedger.push({ - // description: result.description, - // arguments: args, - // undo: result.undo, - // redo: fcn, - // }) - // if (this.undoLedger.length > MAX_UNDO_LEDGER_LENGTH) { - // this.undoLedger.splice(0, this.undoLedger.length - MAX_UNDO_LEDGER_LENGTH) - // } - // this.undoPointer = this.undoLedger.length - 1 - // this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) - // // string represents "anything but undefined" here: - // return (result as UndoableResult).result - // } else { - // return result - // } - // } catch (error) { - // this.callbacks.handleError( - // `Error when calling ${methodName}: ${error}`, - // typeof error === 'object' && (error as any).stack - // ) - // throw error - // } - // } - } - } - } } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types From 5c8e3bb84c7ee090fc299c1e4fe93e38bfa4df3c Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 31 Oct 2023 11:03:24 +0100 Subject: [PATCH 3/6] refactor: undo/redo with browser compatibility --- apps/app/package.json | 1 + apps/app/src/electron/ClientEventBus.ts | 15 +- apps/app/src/electron/EverythingService.ts | 134 ++++++++---------- apps/app/src/electron/SuperConductor.ts | 104 ++++++++------ apps/app/src/electron/UndoService.ts | 110 ++++++++++++++ apps/app/src/electron/api/ApiServer.ts | 2 +- apps/app/src/electron/api/ProjectService.ts | 12 +- apps/app/src/ipc/IPCAPI.ts | 81 ++++++----- apps/app/src/main.ts | 36 ++--- apps/app/src/models/project/Project.ts | 14 ++ apps/app/src/preload.ts | 8 ++ apps/app/src/react/App.tsx | 26 +++- apps/app/src/react/api/ApiClient.ts | 14 +- apps/app/src/react/api/ElectronApi.ts | 3 + .../app/src/react/api/RealtimeDataProvider.ts | 9 +- .../deviceStatuses/DeviceStatuses.tsx | 5 +- .../components/pages/homePage/HomePage.tsx | 2 +- apps/app/src/react/mobx/AnalogStore.ts | 1 - apps/app/src/react/mobx/AppStore.ts | 8 ++ .../src/react/mobx/GDDValidatorStoreStore.ts | 1 - apps/app/src/react/mobx/GroupPlayDataStore.ts | 1 - apps/app/src/react/mobx/GuiStore.ts | 3 +- .../react/mobx/ResourcesAndMetadataStore.ts | 1 - yarn.lock | 5 + 24 files changed, 395 insertions(+), 201 deletions(-) create mode 100644 apps/app/src/electron/UndoService.ts create mode 100644 apps/app/src/preload.ts create mode 100644 apps/app/src/react/api/ElectronApi.ts diff --git a/apps/app/package.json b/apps/app/package.json index 34869639..d44afb08 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -92,6 +92,7 @@ "deepmerge-ts": "^5.1.0", "electron-is-dev": "^2.0.0", "electron-updater": "^5.3.0", + "eventemitter3": "^5.0.1", "file-loader": "^6.2.0", "formik": "^2.2.9", "formik-mui": "^5.0.0-alpha.0", diff --git a/apps/app/src/electron/ClientEventBus.ts b/apps/app/src/electron/ClientEventBus.ts index 25a2ac24..d639f39b 100644 --- a/apps/app/src/electron/ClientEventBus.ts +++ b/apps/app/src/electron/ClientEventBus.ts @@ -11,10 +11,18 @@ import { ActiveAnalog } from '../models/rundown/Analog' import { AnalogInput } from '../models/project/AnalogInput' import { BridgeId } from '@shared/api' import { BridgePeripheralId } from '@shared/lib' -import { EventEmitter } from 'stream' +import EventEmitter from 'eventemitter3' +import { SerializableLedgers } from '../models/project/Project' + +type ClientEventBusEvents = { + callMethod: (...args: any[]) => void // legacy + updateUndoLedgers: (undoLedgers: SerializableLedgers) => void + updateRundown: (rundown: Rundown) => void + updateProject: (rundown: Project) => void +} // --- some of it might be needed, most of it hopefully not -export class ClientEventBus extends EventEmitter implements IPCClientMethods { +export class ClientEventBus extends EventEmitter implements IPCClientMethods { close(): void { // Nothing here } @@ -31,6 +39,9 @@ export class ClientEventBus extends EventEmitter implements IPCClientMethods { updateRundown(_fileName: string, rundown: Rundown): void { this.emit('updateRundown', rundown) // TODO: some type safety, please } + updateUndoLedgers(data: SerializableLedgers): void { + this.emit('updateUndoLedgers', data) // TODO: some type safety, please + } updateResourcesAndMetadata( resources: Array<{ id: ResourceId; resource: ResourceAny | null }>, metadata: SerializedProtectedMap diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index 7bc35d6a..83fce8ad 100644 --- a/apps/app/src/electron/EverythingService.ts +++ b/apps/app/src/electron/EverythingService.ts @@ -40,13 +40,7 @@ import { TSRTimelineContent, DeviceOptionsAny, } from 'timeline-state-resolver-types' -import { - ActionDescription, - IPCServerMethods, - MAX_UNDO_LEDGER_LENGTH, - UndoableResult, - UpdateAppDataOptions, -} from '../ipc/IPCAPI' +import { ActionDescription, IPCServerMethods, UndoableResult, UpdateAppDataOptions } from '../ipc/IPCAPI' import { GroupPreparedPlayData } from '../models/GUI/PreparedPlayhead' import { convertToFilename, ExportProjectData, StorageHandler } from './storageHandler' import { Rundown } from '../models/rundown/Rundown' @@ -64,8 +58,6 @@ import { assertNever, deepClone, getResourceIdFromTimelineObj, omit } from '@sha import { TimelineObj } from '../models/rundown/TimelineObj' import { Project, ProjectBase } from '../models/project/Project' import { AppData } from '../models/App/AppData' -import EventEmitter from 'events' -import TypedEmitter from 'typed-emitter' import { filterMapping, getMappingFromTimelineObject, @@ -88,21 +80,15 @@ import { TriggersHandler } from './triggersHandler' import { GDDSchema, ValidatorCache } from 'graphics-data-definition' import * as RundownActions from './rundownActions' import { SuperConductor } from './SuperConductor' +import { UndoLedgerKey, UndoLedgerService } from './UndoService' +import { SpecialLedgers } from '../models/project/Project' -type UndoLedger = Action[] -type UndoPointer = number -type UndoFunction = () => Promise | void -type UndoableFunction = (...args: any[]) => Promise> -interface Action { - description: ActionDescription - arguments: any[] - redo: UndoableFunction - undo: UndoFunction -} - -type IPCServerEvents = { - updatedUndoLedger: (undoLedger: Readonly, undoPointer: Readonly) => void -} +// type IPCServerEvents = { +// updatedUndoLedger: ( +// undoLedger: Readonly<{ [key: string | symbol]: UndoLedger }>, +// undoPointer: Readonly +// ) => void +// } export function isUndoable(result: unknown): result is UndoableResult { if (typeof result !== 'object' || result === null) { @@ -132,7 +118,9 @@ function Undoable(target: EverythingService, _key: string, descriptor: PropertyD const originalMethod = descriptor.value descriptor.value = async function (...args: any) { const result = await originalMethod.apply(this, args) - target.registerUndoable.call(this, args, originalMethod.bind(this), result) + if (isUndoable(result)) { + target.pushUndoable.call(this, result.ledgerKey, args, originalMethod.bind(this), result) + } return result } return descriptor @@ -142,13 +130,8 @@ function Undoable(target: EverythingService, _key: string, descriptor: PropertyD * This class is used server-side, to handle requests from the client * The methods in here will later be moved away to other Services */ -export class EverythingService - extends (EventEmitter as new () => TypedEmitter) - implements ConvertToServerSide -{ +export class EverythingService implements ConvertToServerSide { public triggers?: TriggersHandler - private undoLedger: UndoLedger = [] - private undoPointer: UndoPointer = -1 constructor( private _log: LoggerLike, @@ -156,6 +139,7 @@ export class EverythingService private storage: StorageHandler, private superConductor: SuperConductor, private session: SessionHandler, + private undoService: UndoLedgerService, private callbacks: { onClientConnected: () => void installUpdate: () => void @@ -169,25 +153,10 @@ export class EverythingService onAgreeToUserAgreement: () => void handleError: (error: string, stack?: string) => void } - ) { - super() - } + ) {} - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - public registerUndoable(args: any, fcn: () => any, result: UndoableResult): void { - this.undoLedger.splice(this.undoPointer + 1, this.undoLedger.length) - // Add the new action to the undo ledger: - this.undoLedger.push({ - description: result.description, - arguments: args, - undo: result.undo, - redo: fcn, - }) - if (this.undoLedger.length > MAX_UNDO_LEDGER_LENGTH) { - this.undoLedger.splice(0, this.undoLedger.length - MAX_UNDO_LEDGER_LENGTH) - } - this.undoPointer = this.undoLedger.length - 1 - this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) + public pushUndoable(key: UndoLedgerKey, args: unknown[], fcn: () => any, result: UndoableResult): void { + this.undoService.pushUndoable(key, args, fcn, result) } public getProject(): Project { @@ -254,34 +223,11 @@ export class EverythingService return { rundown, group, part } } - async undo(): Promise { - const action = this.undoLedger[this.undoPointer] - try { - await action.undo() - this.undoPointer-- - } catch (error) { - this._log.error('Error when undoing:', error) - - // Clear - this.undoLedger.splice(0, this.undoLedger.length) - this.undoPointer = -1 - } - this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) + async undo(key: string): Promise { + await this.undoService.undo(key) } - async redo(): Promise { - const action = this.undoLedger[this.undoPointer + 1] - try { - const redoResult = await action.redo(...action.arguments) - action.undo = redoResult.undo - this.undoPointer++ - } catch (error) { - this._log.error('Error when redoing:', error) - - // Clear - this.undoLedger.splice(0, this.undoLedger.length) - this.undoPointer = -1 - } - this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) + async redo(key: string): Promise { + await this.undoService.redo(key) } async log(arg: { level: LogLevel; params: any[] }): Promise { @@ -498,6 +444,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, noEffectOnPlayout: true }) }, description: ActionDescription.SetPartTrigger, + ledgerKey: arg.rundownId, } } async stopGroup(arg: { rundownId: string; groupId: string }): Promise { @@ -654,6 +601,7 @@ export class EverythingService }, description: ActionDescription.NewPart, result, + ledgerKey: arg.rundownId, } } @Undoable @@ -753,6 +701,7 @@ export class EverythingService }, description: ActionDescription.InsertParts, result: inserted, + ledgerKey: arg.rundownId, } } else { return this._insertPartsAsTransparentGroup({ @@ -814,6 +763,7 @@ export class EverythingService return { ...r, result: inserted, + ledgerKey: arg.rundownId, } } @Undoable @@ -858,6 +808,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.UpdatePart, + ledgerKey: arg.rundownId, } } @Undoable @@ -920,6 +871,7 @@ export class EverythingService } }, description: ActionDescription.UpsertPart, + ledgerKey: arg.rundownId, } } @Undoable @@ -964,6 +916,7 @@ export class EverythingService }, description: ActionDescription.NewGroup, result: newGroup.id, + ledgerKey: arg.rundownId, } } @Undoable @@ -1056,6 +1009,7 @@ export class EverythingService }, description: ActionDescription.InsertGroups, result: inserted, + ledgerKey: arg.rundownId, } } @Undoable @@ -1106,6 +1060,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.UpdateGroup, + ledgerKey: arg.rundownId, } } @Undoable @@ -1196,6 +1151,7 @@ export class EverythingService } }, description: ActionDescription.UpsertGroup, + ledgerKey: arg.rundownId, } } @Undoable @@ -1262,6 +1218,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.DeletePart, + ledgerKey: arg.rundownId, } } @Undoable @@ -1293,6 +1250,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group: deletedGroup }) }, description: ActionDescription.DeleteGroup, + ledgerKey: arg.rundownId, } } @Undoable @@ -1479,6 +1437,7 @@ export class EverythingService }, description: ActionDescription.MovePart, result: resultingParts, + ledgerKey: arg.to.rundownId, } } @Undoable @@ -1529,6 +1488,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group: newGroup ? undefined : group }) }, description: ActionDescription.DuplicatePart, + ledgerKey: arg.rundownId, } } @Undoable @@ -1573,6 +1533,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.MoveGroup, + ledgerKey: arg.rundownId, } } @Undoable @@ -1599,6 +1560,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.DuplicateGroup, + ledgerKey: arg.rundownId, } } @@ -1640,6 +1602,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.UpdateTimelineObj, + ledgerKey: arg.rundownId, } } @@ -1683,6 +1646,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.DeleteTimelineObj, + ledgerKey: arg.rundownId, } } @Undoable @@ -1809,6 +1773,7 @@ export class EverythingService }, description: ActionDescription.AddTimelineObj, result: inserted, + ledgerKey: arg.rundownId, } } @Undoable @@ -1863,6 +1828,7 @@ export class EverythingService this._saveUpdates({ project: updatedProject, rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.MoveTimelineObjToNewLayer, + ledgerKey: arg.rundownId, } } @@ -1986,6 +1952,7 @@ export class EverythingService this._saveUpdates({ project: updatedProject, rundownId: arg.rundownId, rundown }) }, description: ActionDescription.addResourcesToTimeline, + ledgerKey: arg.rundownId, } } @Undoable @@ -2017,6 +1984,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.ToggleGroupLoop, + ledgerKey: arg.rundownId, } } @Undoable @@ -2048,6 +2016,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.ToggleGroupAutoplay, + ledgerKey: arg.rundownId, } } @Undoable @@ -2090,6 +2059,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.toggleGroupOneAtATime, + ledgerKey: arg.rundownId, } } @Undoable @@ -2121,6 +2091,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.ToggleGroupDisable, + ledgerKey: arg.rundownId, } } @Undoable @@ -2141,6 +2112,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group, noEffectOnPlayout: true }) }, description: ActionDescription.ToggleGroupLock, + ledgerKey: arg.rundownId, } } @Undoable @@ -2165,6 +2137,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group, noEffectOnPlayout: true }) }, description: ActionDescription.ToggleGroupCollapse, + ledgerKey: arg.rundownId, } } @Undoable @@ -2193,6 +2166,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, noEffectOnPlayout: true }) }, description: ActionDescription.ToggleAllGroupsCollapse, + ledgerKey: arg.rundownId, } } async refreshResources(): Promise { @@ -2248,6 +2222,7 @@ export class EverythingService }, description: ActionDescription.NewRundown, result: rundown, + ledgerKey: rundown.id, } } async deleteRundown(arg: { rundownId: string }): Promise { @@ -2278,6 +2253,7 @@ export class EverythingService this._saveUpdates({}) }, description: ActionDescription.OpenRundown, + ledgerKey: SpecialLedgers.APPLICATION, } } @Undoable @@ -2301,6 +2277,7 @@ export class EverythingService this._saveUpdates({}) }, description: ActionDescription.CloseRundown, + ledgerKey: SpecialLedgers.APPLICATION, } } @@ -2322,6 +2299,7 @@ export class EverythingService }, description: ActionDescription.RenameRundown, result: newRundownId, + ledgerKey: arg.rundownId, } } async isRundownPlaying(arg: { rundownId: string }): Promise { @@ -2432,6 +2410,7 @@ export class EverythingService } }, description: ActionDescription.CreateMissingMapping, + ledgerKey: arg.rundownId, } } @@ -2479,6 +2458,7 @@ export class EverythingService } }, description: ActionDescription.AddPeripheralArea, + ledgerKey: SpecialLedgers.PERIPHERALS, } } @Undoable @@ -2522,6 +2502,7 @@ export class EverythingService } }, description: ActionDescription.RemovePeripheralArea, + ledgerKey: SpecialLedgers.PERIPHERALS, } } @Undoable @@ -2570,6 +2551,7 @@ export class EverythingService } }, description: ActionDescription.UpdatePeripheralArea, + ledgerKey: SpecialLedgers.PERIPHERALS, } } @Undoable @@ -2609,6 +2591,7 @@ export class EverythingService this._saveUpdates({ project }) }, description: ActionDescription.AssignAreaToGroup, + ledgerKey: SpecialLedgers.PERIPHERALS, } } async startDefiningArea(arg: { bridgeId: BridgeId; deviceId: PeripheralId; areaId: string }): Promise { @@ -2676,6 +2659,7 @@ export class EverythingService this._saveUpdates({ appData, noEffectOnPlayout: true }) }, description: ActionDescription.SetApplicationTrigger, + ledgerKey: SpecialLedgers.APPLICATION, } } diff --git a/apps/app/src/electron/SuperConductor.ts b/apps/app/src/electron/SuperConductor.ts index e13866eb..2a843588 100644 --- a/apps/app/src/electron/SuperConductor.ts +++ b/apps/app/src/electron/SuperConductor.ts @@ -41,6 +41,7 @@ import { ActiveAnalog } from '../models/rundown/Analog' import { AnalogHandler } from './analogHandler' import { AnalogInput } from '../models/project/AnalogInput' import { SystemMessageOptions } from '../ipc/IPCAPI' +import { UndoLedgerService } from './UndoService' export class SuperConductor { ipcServer: EverythingService @@ -225,53 +226,66 @@ export class SuperConductor { }, }) - this.ipcServer = new EverythingService(this.log, this.renderLog, this.storage, this, this.session, { - refreshResources: () => { - this.refreshResources() - }, - refreshResourcesSetAuto: (interval: number) => { - const project = this.storage.getProject() - project.autoRefreshInterval = interval - this.storage.updateProject(project) - }, - onClientConnected: () => { - // Nothing here yet - }, - installUpdate: () => { - autoUpdater.autoRunAppAfterInstall = true - autoUpdater.quitAndInstall() - }, - updateTimeline: (group: Group): GroupPreparedPlayData | null => { - return this.updateTimeline(group) - }, - updatePeripherals: (): void => { - this.triggers?.triggerUpdatePeripherals() - this.analogHandler?.triggerUpdatePeripherals() - }, - setKeyboardKeys: (activeKeys: ActiveTrigger[]): void => { - this.triggers?.setKeyboardKeys(activeKeys) - }, - triggerHandleAutoFill: () => { - this.triggerHandleAutoFill() - }, - makeDevData: async () => { - await this.storage.makeDevData() - }, - onAgreeToUserAgreement: () => { - this.telemetryHandler.setUserHasAgreed() - this.telemetryHandler.onAcceptUserAgreement() - - if (!this.hasStoredStartupUserStatistics) { - this.hasStoredStartupUserStatistics = true - this.telemetryHandler.onStartup() - } - }, - handleError: (error: string, stack?: string) => { - this.log.error(error, stack) - this.telemetryHandler.onError(error, stack) - }, + const undoLedgerService = new UndoLedgerService(this.log) + undoLedgerService.on('updatedUndoLedger', (data) => { + this.clientEventBus.updateUndoLedgers(data) }) + this.ipcServer = new EverythingService( + this.log, + this.renderLog, + this.storage, + this, + this.session, + undoLedgerService, + { + refreshResources: () => { + this.refreshResources() + }, + refreshResourcesSetAuto: (interval: number) => { + const project = this.storage.getProject() + project.autoRefreshInterval = interval + this.storage.updateProject(project) + }, + onClientConnected: () => { + // Nothing here yet + }, + installUpdate: () => { + autoUpdater.autoRunAppAfterInstall = true + autoUpdater.quitAndInstall() + }, + updateTimeline: (group: Group): GroupPreparedPlayData | null => { + return this.updateTimeline(group) + }, + updatePeripherals: (): void => { + this.triggers?.triggerUpdatePeripherals() + this.analogHandler?.triggerUpdatePeripherals() + }, + setKeyboardKeys: (activeKeys: ActiveTrigger[]): void => { + this.triggers?.setKeyboardKeys(activeKeys) + }, + triggerHandleAutoFill: () => { + this.triggerHandleAutoFill() + }, + makeDevData: async () => { + await this.storage.makeDevData() + }, + onAgreeToUserAgreement: () => { + this.telemetryHandler.setUserHasAgreed() + this.telemetryHandler.onAcceptUserAgreement() + + if (!this.hasStoredStartupUserStatistics) { + this.hasStoredStartupUserStatistics = true + this.telemetryHandler.onStartup() + } + }, + handleError: (error: string, stack?: string) => { + this.log.error(error, stack) + this.telemetryHandler.onError(error, stack) + }, + } + ) + this.triggers = new TriggersHandler(this.log, this.storage, this.ipcServer, this.bridgeHandler, this.session) this.triggers.on('error', (e) => this.log.error(e)) this.triggers.on('failedGlobalTriggers', (failedGlobalTriggers) => { diff --git a/apps/app/src/electron/UndoService.ts b/apps/app/src/electron/UndoService.ts new file mode 100644 index 00000000..376a236c --- /dev/null +++ b/apps/app/src/electron/UndoService.ts @@ -0,0 +1,110 @@ +import { LoggerLike } from '@shared/api' +import { ActionDescription, UndoableResult } from '../ipc/IPCAPI' +import EventEmitter from 'eventemitter3' +import _ from 'lodash' +import { SerializableLedgers } from '../models/project/Project' +import { SpecialLedgers } from '../models/project/Project' + +export const MAX_UNDO_LEDGER_LENGTH = 100 + +export interface UndoLedger { + actions: Action[] + pointer: UndoPointer +} +export type UndoLedgerKey = string | SpecialLedgers +type UndoPointer = number +type UndoFunction = () => Promise | void +export type UndoableFunction = (...args: any[]) => Promise> +interface Action { + description: ActionDescription + arguments: unknown[] + redo: UndoableFunction + undo: UndoFunction +} + +type UndoServiceEvents = { + updatedUndoLedger: (undoLedgers: SerializableLedgers) => void +} + +export class UndoLedgerService extends EventEmitter { + constructor(private _log: LoggerLike) { + super() + } + private undoLedgers = new Map() + + public pushUndoable(key: UndoLedgerKey, args: unknown[], fcn: () => any, result: UndoableResult): void { + let ledger = this.undoLedgers.get(key) + if (!ledger) { + ledger = this.makeEmptyLedger() + this.undoLedgers.set(key, ledger) + } + ledger.actions.splice(ledger.pointer + 1, ledger.actions.length) + // Add the new action to the undo ledger: + ledger.actions.push({ + description: result.description, + arguments: args, + undo: result.undo, + redo: fcn, + }) + if (ledger.actions.length > MAX_UNDO_LEDGER_LENGTH) { + ledger.actions.splice(0, ledger.actions.length - MAX_UNDO_LEDGER_LENGTH) + } + ledger.pointer = ledger.actions.length - 1 + this.emitUpdate() + } + + private emitUpdate() { + this.emit('updatedUndoLedger', this.toSerializable()) + } + + public async undo(key: UndoLedgerKey): Promise { + const ledger = this.undoLedgers.get(key) + const action = ledger?.actions?.[ledger?.pointer] + if (!action) return + try { + await action.undo() + ledger.pointer-- + } catch (error) { + this._log.error('Error when undoing:', error) + + // Clear + this.undoLedgers.set(key, this.makeEmptyLedger()) + } + this.emitUpdate() + } + + public async redo(key: UndoLedgerKey): Promise { + const ledger = this.undoLedgers.get(key) + const action = ledger?.actions?.[ledger?.pointer + 1] + if (!action) return + try { + const redoResult = await action.redo(...action.arguments) + action.undo = redoResult.undo + ledger.pointer++ + } catch (error) { + this._log.error('Error when redoing:', error) + + // Clear + this.undoLedgers.set(key, this.makeEmptyLedger()) + } + this.emitUpdate() + } + + private makeEmptyLedger(): UndoLedger { + return { + actions: [], + pointer: -1, + } + } + + private toSerializable(): SerializableLedgers { + return _.mapValues(Object.fromEntries(this.undoLedgers.entries()), (ledger: UndoLedger) => ({ + undo: ledger.actions[ledger.pointer] + ? { description: ledger.actions[ledger.pointer]?.description } + : undefined, + redo: ledger.actions[ledger.pointer + 1] + ? { description: ledger.actions[ledger.pointer + 1]?.description } + : undefined, + })) + } +} diff --git a/apps/app/src/electron/api/ApiServer.ts b/apps/app/src/electron/api/ApiServer.ts index d70893cc..3ab73c58 100644 --- a/apps/app/src/electron/api/ApiServer.ts +++ b/apps/app/src/electron/api/ApiServer.ts @@ -41,7 +41,7 @@ export class ApiServer { this.app.use(ServiceName.PROJECTS, new ProjectService(this.app, ipcServer, clientEventBus), { methods: ClientMethods[ServiceName.PROJECTS], - serviceEvents: ['created', ProjectsEvents.UPDATED, 'deleted'], + serviceEvents: ['created', ProjectsEvents.UPDATED, 'deleted', ProjectsEvents.UNDO_LEDGERS_UPDATED], }) this.app.use(ServiceName.PARTS, new PartService(this.app, ipcServer, clientEventBus), { diff --git a/apps/app/src/electron/api/ProjectService.ts b/apps/app/src/electron/api/ProjectService.ts index 9f23ab78..8c164ab2 100644 --- a/apps/app/src/electron/api/ProjectService.ts +++ b/apps/app/src/electron/api/ProjectService.ts @@ -4,6 +4,7 @@ import EventEmitter from 'node:events' import { ProjectsEvents, ServiceTypes } from '../../ipc/IPCAPI' import { Project, ProjectBase } from '../../models/project/Project' import { ClientEventBus } from '../ClientEventBus' +import { SerializableLedgers } from '../../models/project/Project' export const PROJECTS_CHANNEL_PREFIX = 'projects' export class ProjectService extends EventEmitter { @@ -16,6 +17,9 @@ export class ProjectService extends EventEmitter { clientEventBus.on('updateProject', (project: Project) => { this.emit(ProjectsEvents.UPDATED, project) }) + clientEventBus.on('updateUndoLedgers', (ledgers: SerializableLedgers) => { + this.emit(ProjectsEvents.UNDO_LEDGERS_UPDATED, ledgers) + }) } async get(_id: string): Promise { @@ -66,15 +70,15 @@ export class ProjectService extends EventEmitter { return await this.everythingService.importProject() } - async undo(): Promise { + async undo(data: { key: string }): Promise { // TODO: likely not the best place for this method // TODO: access control - return await this.everythingService.undo() + return await this.everythingService.undo(data.key) } - async redo(): Promise { + async redo(data: { key: string }): Promise { // TODO: likely not the best place for this method // TODO: access control - return await this.everythingService.redo() + return await this.everythingService.redo(data.key) } } diff --git a/apps/app/src/ipc/IPCAPI.ts b/apps/app/src/ipc/IPCAPI.ts index 22f8c8bd..428bfcc2 100644 --- a/apps/app/src/ipc/IPCAPI.ts +++ b/apps/app/src/ipc/IPCAPI.ts @@ -1,6 +1,6 @@ import { PartialDeep } from 'type-fest' import { BridgeStatus } from '../models/project/Bridge' -import { Project } from '../models/project/Project' +import { Project, SerializableLedger } from '../models/project/Project' import { ResourceAny, ResourceId, MetadataAny, SerializedProtectedMap, TSRDeviceId } from '@shared/models' import { Rundown } from '../models/rundown/Rundown' import { TimelineObj } from '../models/rundown/TimelineObj' @@ -23,8 +23,7 @@ import { type ProjectService } from '../electron/api/ProjectService' import { type ReportingService } from '../electron/api/ReportingService' import { type RundownService } from '../electron/api/RundownService' import { type GroupService } from '../electron/api/GroupService' - -export const MAX_UNDO_LEDGER_LENGTH = 100 +import { type SpecialLedgers } from '../models/project/Project' export enum ServiceName { GROUPS = 'groups', @@ -117,50 +116,55 @@ export const ClientMethods: ServiceKeyArrays = { } export const enum ActionDescription { - NewPart = 'create new part', - InsertParts = 'insert part(s)', - UpdatePart = 'update part', + NewPart = 'Create new part', + InsertParts = 'Insert part(s)', + UpdatePart = 'Update part', SetPartTrigger = 'Assign trigger', - NewGroup = 'create new group', - InsertGroups = 'insert group(s)', - UpdateGroup = 'update group', - DeletePart = 'delete part', - DeleteGroup = 'delete group', - MovePart = 'move part', - MoveGroup = 'move group', - UpdateTimelineObj = 'update timeline object', - DeleteTimelineObj = 'delete timeline object', - AddTimelineObj = 'add timeline obj', - addResourcesToTimeline = 'add resource to timeline', - ToggleGroupLoop = 'toggle group loop', - ToggleGroupAutoplay = 'toggle group autoplay', - toggleGroupOneAtATime = 'toggle group one-at-a-time', - ToggleGroupDisable = 'toggle group disable', - ToggleGroupLock = 'toggle group lock', - ToggleGroupCollapse = 'toggle group collapse', - ToggleAllGroupsCollapse = 'toggle all groups collapse', - NewRundown = 'new rundown', - DeleteRundown = 'delete rundown', - OpenRundown = 'open rundown', - CloseRundown = 'close rundown', - RenameRundown = 'rename rundown', - MoveTimelineObjToNewLayer = 'move timeline object to new layer', - CreateMissingMapping = 'create missing layer', - DuplicateGroup = 'duplicate group', - DuplicatePart = 'duplicate part', + NewGroup = 'Create new group', + InsertGroups = 'Insert group(s)', + UpdateGroup = 'Update group', + DeletePart = 'Delete part', + DeleteGroup = 'Delete group', + MovePart = 'Move part', + MoveGroup = 'Move group', + UpdateTimelineObj = 'Update timeline object', + DeleteTimelineObj = 'Delete timeline object', + AddTimelineObj = 'Add timeline obj', + addResourcesToTimeline = 'Add resource to timeline', + ToggleGroupLoop = 'Toggle group loop', + ToggleGroupAutoplay = 'Toggle group autoplay', + toggleGroupOneAtATime = 'Toggle group one-at-a-time', + ToggleGroupDisable = 'Toggle group disable', + ToggleGroupLock = 'Toggle group lock', + ToggleGroupCollapse = 'Toggle group collapse', + ToggleAllGroupsCollapse = 'Toggle all groups collapse', + NewRundown = 'New rundown', + DeleteRundown = 'Delete rundown', + OpenRundown = 'Open rundown', + CloseRundown = 'Close rundown', + RenameRundown = 'Rename rundown', + MoveTimelineObjToNewLayer = 'Move timeline object to new layer', + CreateMissingMapping = 'Create missing layer', + DuplicateGroup = 'Duplicate group', + DuplicatePart = 'Duplicate part', AddPeripheralArea = 'Add button area', UpdatePeripheralArea = 'Update button area', RemovePeripheralArea = 'Remove button area', AssignAreaToGroup = 'Assign Area to Group', // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values SetApplicationTrigger = 'Assign trigger', - UpsertGroup = 'upsert group', - UpsertPart = 'upsert part', + UpsertGroup = 'Upsert group', + UpsertPart = 'Upsert part', } export type UndoFunction = () => Promise | void -export type UndoableResult = { undo: UndoFunction; description: ActionDescription; result?: T } +export type UndoableResult = { + ledgerKey: string | SpecialLedgers + undo: UndoFunction + description: ActionDescription + result?: T +} export type UndoableFunction = (...args: any[]) => Promise @@ -171,6 +175,10 @@ export interface Action { undo: UndoFunction } +export interface ElectronAPI { + updateUndoLedger: (key: string, data: SerializableLedger) => void +} + // --- legacy /** Methods that can be called on the server, by the client */ export interface IPCServerMethods { @@ -369,4 +377,5 @@ export enum RundownsEvents { } export enum ProjectsEvents { UPDATED = 'updated', + UNDO_LEDGERS_UPDATED = 'undo_ledgers_updated', } diff --git a/apps/app/src/main.ts b/apps/app/src/main.ts index 942576dc..86b88124 100644 --- a/apps/app/src/main.ts +++ b/apps/app/src/main.ts @@ -1,5 +1,5 @@ import { literal, stringifyError } from '@shared/lib' -import { app, BrowserWindow, dialog, Menu, shell, screen } from 'electron' +import { app, BrowserWindow, dialog, Menu, shell, screen, ipcMain } from 'electron' import isDev from 'electron-is-dev' import { autoUpdater } from 'electron-updater' import { CURRENT_VERSION } from './electron/bridgeHandler' @@ -9,6 +9,7 @@ import { createLoggers } from './lib/logging' import { baseFolder } from './lib/baseFolder' import path from 'path' import winston from 'winston' +import { SerializableLedger } from './models/project/Project' function createWindow(log: winston.Logger, superConductor: SuperConductor): void { const appData = superConductor.storage.getAppData() @@ -20,8 +21,9 @@ function createWindow(log: winston.Logger, superConductor: SuperConductor): void height: appData.windowPosition.height, webPreferences: { - nodeIntegration: true, - contextIsolation: false, + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js'), }, title: 'SuperConductor', }) @@ -64,12 +66,10 @@ function createWindow(log: winston.Logger, superConductor: SuperConductor): void undoEnabled: false, redoLabel: 'Redo', redoEnabled: false, - onUndoClick: () => { - superConductor.ipcServer.undo().catch(log.error) - }, - onRedoClick: () => { - superConductor.ipcServer.redo().catch(log.error) - }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onUndoClick: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onRedoClick: () => {}, onAboutClick: () => { // TODO: this should probably become a client-side only action // handler.ipcClient.displayAboutDialog() @@ -113,13 +113,17 @@ function createWindow(log: winston.Logger, superConductor: SuperConductor): void const menu = generateMenu(menuOpts, log) Menu.setApplicationMenu(menu) - superConductor.ipcServer.on('updatedUndoLedger', (undoLedger, undoPointer) => { - const undoAction = undoLedger[undoPointer] - const redoAction = undoLedger[undoPointer + 1] - menuOpts.undoLabel = undoAction ? `Undo ${undoAction.description}` : 'Undo' - menuOpts.undoEnabled = Boolean(undoAction) - menuOpts.redoLabel = redoAction ? `Redo ${redoAction.description}` : 'Redo' - menuOpts.redoEnabled = Boolean(redoAction) + ipcMain.on('updateUndoLedger', (_event, key: string, undoLedger: SerializableLedger) => { + menuOpts.undoLabel = undoLedger.undo ? `Undo ${undoLedger.undo.description}` : 'Undo' + menuOpts.undoEnabled = Boolean(undoLedger.undo) + menuOpts.onUndoClick = () => { + superConductor.ipcServer.undo(key).catch(log.error) + } + menuOpts.redoLabel = undoLedger.redo ? `Redo ${undoLedger.redo.description}` : 'Redo' + menuOpts.redoEnabled = Boolean(undoLedger.redo) + menuOpts.onRedoClick = () => { + superConductor.ipcServer.redo(key).catch(log.error) + } const menu = generateMenu(menuOpts, log) Menu.setApplicationMenu(menu) }) diff --git a/apps/app/src/models/project/Project.ts b/apps/app/src/models/project/Project.ts index 47501c2f..78f3cf0c 100644 --- a/apps/app/src/models/project/Project.ts +++ b/apps/app/src/models/project/Project.ts @@ -40,3 +40,17 @@ export interface AnalogInputSetting { relativeMaxCap?: number absoluteOffset?: number } + +export interface SerializableLedger { + undo?: { description?: string } + redo?: { description?: string } +} + +export interface SerializableLedgers { + [key: string]: SerializableLedger +} + +export enum SpecialLedgers { + APPLICATION = 'application', + PERIPHERALS = 'peripherals', +} diff --git a/apps/app/src/preload.ts b/apps/app/src/preload.ts new file mode 100644 index 00000000..5d176046 --- /dev/null +++ b/apps/app/src/preload.ts @@ -0,0 +1,8 @@ +import { contextBridge, ipcRenderer } from 'electron' +import { ElectronAPI } from './ipc/IPCAPI' + +contextBridge.exposeInMainWorld('electronAPI', { + updateUndoLedger: (key, data) => { + ipcRenderer.send('updateUndoLedger', key, data) + }, +} satisfies ElectronAPI) diff --git a/apps/app/src/react/App.tsx b/apps/app/src/react/App.tsx index 65580273..4d0b3487 100644 --- a/apps/app/src/react/App.tsx +++ b/apps/app/src/react/App.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -// const { ipcRenderer } = window.require('electron') import '@fontsource/barlow/300.css' import '@fontsource/barlow/400.css' @@ -15,7 +14,7 @@ import { Sidebar } from './components/sidebar/Sidebar' import sorensen from '@sofie-automation/sorensen' import { RealtimeDataProvider } from './api/RealtimeDataProvider' import { ApiClient } from './api/ApiClient' -import { Project } from '../models/project/Project' +import { Project, SpecialLedgers } from '../models/project/Project' import { IPCServerContext } from './contexts/IPCServer' import { ProjectContext } from './contexts/Project' import { HotkeyContext, IHotkeyContext, TriggersEmitter } from './contexts/Hotkey' @@ -50,6 +49,7 @@ import { TextBtn } from './components/inputs/textBtn/TextBtn' import { HiOutlineX, HiDotsVertical } from 'react-icons/hi' import { protectString } from '@shared/models' import { PERIPHERAL_KEYBOARD } from '../models/project/Peripheral' +import { ElectronApi } from './api/ElectronApi' /** * Used to remove unnecessary cruft from error messages. @@ -491,6 +491,13 @@ export const App = observer(function App() { } }, [sorensenInitialized, handleError, gui, currentRundownId, deleteSelectedTimelineObjs]) + const undoLedgerKey = useMemoComputedValue(() => { + let key: string = SpecialLedgers.APPLICATION + if (gui.isPeripheralPopoverOpen) key = SpecialLedgers.PERIPHERALS + if (currentRundownId && !gui.isHomeSelected()) key = currentRundownId + return key + }, [gui, currentRundownId]) + useEffect(() => { if (!sorensenInitialized) { return @@ -521,13 +528,11 @@ export const App = observer(function App() { } function onUndo(): void { setUserAgreementScreenOpen(false) - // eslint-disable-next-line @typescript-eslint/unbound-method - serverAPI.undo().catch(handleError) + if (undoLedgerKey) serverAPI.undo({ key: undoLedgerKey }).catch(handleError) } function onRedo(): void { setUserAgreementScreenOpen(false) - // eslint-disable-next-line @typescript-eslint/unbound-method - serverAPI.redo().catch(handleError) + if (undoLedgerKey) serverAPI.redo({ key: undoLedgerKey }).catch(handleError) } sorensen.bind('Escape', onEscapeKey, { up: false, @@ -548,7 +553,14 @@ export const App = observer(function App() { sorensen.unbind('Control+KeyZ', onUndo) sorensen.unbind('Control+KeyY', onRedo) } - }, [sorensenInitialized, handleError, gui, currentRundownId, serverAPI]) + }, [sorensenInitialized, handleError, gui, currentRundownId, serverAPI, undoLedgerKey]) + + useEffect(() => { + if (ElectronApi) { + const ledger = store.appStore.undoLedgers[undoLedgerKey] ?? {} + ElectronApi.updateUndoLedger(undoLedgerKey, JSON.parse(JSON.stringify(ledger))) + } + }, [undoLedgerKey, store.appStore.undoLedgers]) useMemoComputedValue(() => { if (!project) return diff --git a/apps/app/src/react/api/ApiClient.ts b/apps/app/src/react/api/ApiClient.ts index f44b449f..380fe34f 100644 --- a/apps/app/src/react/api/ApiClient.ts +++ b/apps/app/src/react/api/ApiClient.ts @@ -21,9 +21,9 @@ type PartArg = Parameters = Parameters< ServiceTypes[ServiceName.RUNDOWNS][T] >[0] -// type ProjectArg = Parameters< -// ServiceTypes[ServiceName.PROJECTS][T] -// >[0] +type ProjectArg = Parameters< + ServiceTypes[ServiceName.PROJECTS][T] +>[0] type ReportingArg = Parameters< ServiceTypes[ServiceName.REPORTING][T] >[0] @@ -330,10 +330,10 @@ export class ApiClient { async setApplicationTrigger(...args: ServerArgs<'setApplicationTrigger'>): ServerReturn<'setApplicationTrigger'> { return this.invokeServerMethod('setApplicationTrigger', ...args) } - async undo(): Promise { - return this.projectService.undo() + async undo(data: ProjectArg<'undo'>): Promise { + return this.projectService.undo(data) } - async redo(): Promise { - return this.projectService.redo() + async redo(data: ProjectArg<'redo'>): Promise { + return this.projectService.redo(data) } } diff --git a/apps/app/src/react/api/ElectronApi.ts b/apps/app/src/react/api/ElectronApi.ts new file mode 100644 index 00000000..d26a3245 --- /dev/null +++ b/apps/app/src/react/api/ElectronApi.ts @@ -0,0 +1,3 @@ +import { ElectronAPI } from '../../ipc/IPCAPI' + +export const ElectronApi = (window as any).electronAPI ? ((window as any).electronAPI as ElectronAPI) : undefined diff --git a/apps/app/src/react/api/RealtimeDataProvider.ts b/apps/app/src/react/api/RealtimeDataProvider.ts index 01283b12..5f5b9102 100644 --- a/apps/app/src/react/api/RealtimeDataProvider.ts +++ b/apps/app/src/react/api/RealtimeDataProvider.ts @@ -13,6 +13,7 @@ import { AnalogInput } from '../../models/project/AnalogInput' import { BridgeId } from '@shared/api' import { BridgePeripheralId } from '@shared/lib' import { app } from './ApiClient' +import { SerializableLedgers } from '../../models/project/Project' /** This class is used client-side, to handle messages from the server */ export class RealtimeDataProvider { @@ -37,6 +38,7 @@ export class RealtimeDataProvider { updateDefiningArea?: (definingArea: DefiningArea | null) => void updateFailedGlobalTriggers?: (identifiers: string[]) => void updateAnalogInput?: (fullIdentifier: string, analogInput: AnalogInput | null) => void + updateUndoLedgers?: (data: SerializableLedgers) => void } ) { // this is new: @@ -44,7 +46,9 @@ export class RealtimeDataProvider { this.updateRundown(rundown.id, rundown) ) app.service(ServiceName.PROJECTS).on(ProjectsEvents.UPDATED, (project) => this.updateProject(project)) - // app.service('project').on(...) etc. + app.service(ServiceName.PROJECTS).on(ProjectsEvents.UNDO_LEDGERS_UPDATED, (undoLedgers) => + this.updateUndoLedgers(undoLedgers) + ) // this is temporary: app.service(ServiceName.LEGACY).on('callMethod', (args) => this.handleCallMethod(args[0], args.slice(1))) @@ -73,6 +77,9 @@ export class RealtimeDataProvider { updateRundown(fileName: string, rundown: Rundown): void { this.callbacks.updateRundown?.(fileName, rundown) } + updateUndoLedgers(data: SerializableLedgers): void { + this.callbacks.updateUndoLedgers?.(data) + } updateResourcesAndMetadata( resources: Array<{ id: ResourceId; resource: ResourceAny | null }>, metadata: SerializedProtectedMap diff --git a/apps/app/src/react/components/headerBar/deviceStatuses/DeviceStatuses.tsx b/apps/app/src/react/components/headerBar/deviceStatuses/DeviceStatuses.tsx index 5f1bd699..366dd99a 100644 --- a/apps/app/src/react/components/headerBar/deviceStatuses/DeviceStatuses.tsx +++ b/apps/app/src/react/components/headerBar/deviceStatuses/DeviceStatuses.tsx @@ -18,6 +18,7 @@ import { getPeripheralId } from '@shared/lib' export const DeviceStatuses: React.FC = observer(function DeviceStatuses() { const project = useContext(ProjectContext) const appStore = store.appStore + const gui = store.guiStore const [submenuPopover, setSubmenuPopover] = React.useState<{ anchorEl: HTMLAnchorElement @@ -26,7 +27,8 @@ export const DeviceStatuses: React.FC = observer(function DeviceStatuses() { } | null>(null) const closeSubMenu = useCallback(() => { setSubmenuPopover(null) - }, []) + gui.isPeripheralPopoverOpen = true + }, [gui]) const [disabledPeripheralsPopover, setDisabledPeripheralsPopover] = React.useState<{ anchorEl: HTMLButtonElement @@ -144,6 +146,7 @@ export const DeviceStatuses: React.FC = observer(function DeviceStatuses() { bridgeId: peripheral.bridgeId, deviceId: peripheral.id, }) + gui.isPeripheralPopoverOpen = true }} /> ) diff --git a/apps/app/src/react/components/pages/homePage/HomePage.tsx b/apps/app/src/react/components/pages/homePage/HomePage.tsx index 17a8c493..bc8ff8b2 100644 --- a/apps/app/src/react/components/pages/homePage/HomePage.tsx +++ b/apps/app/src/react/components/pages/homePage/HomePage.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Project } from 'src/models/project/Project' +import { Project } from '../../../../models/project/Project' import { observer } from 'mobx-react-lite' import { store } from '../../../mobx/store' import { ProjectPage } from './projectPage/ProjectPage' diff --git a/apps/app/src/react/mobx/AnalogStore.ts b/apps/app/src/react/mobx/AnalogStore.ts index 7e300d3e..c20f9de5 100644 --- a/apps/app/src/react/mobx/AnalogStore.ts +++ b/apps/app/src/react/mobx/AnalogStore.ts @@ -4,7 +4,6 @@ import { ActiveAnalog } from '../../models/rundown/Analog' import { RealtimeDataProvider } from '../api/RealtimeDataProvider' import { ApiClient } from '../api/ApiClient' import { ClientSideLogger } from '../api/logger' -// const { ipcRenderer } = window.require('electron') export class AnalogStore { private analogInputs = new Map() diff --git a/apps/app/src/react/mobx/AppStore.ts b/apps/app/src/react/mobx/AppStore.ts index 1f492138..312eb49b 100644 --- a/apps/app/src/react/mobx/AppStore.ts +++ b/apps/app/src/react/mobx/AppStore.ts @@ -9,10 +9,13 @@ import { setConstants } from '../constants' import { BridgeId } from '@shared/api' import { TSRDeviceId, protectString } from '@shared/models' import { BridgePeripheralId } from '@shared/lib' +import { SerializableLedgers, SpecialLedgers } from '../../models/project/Project' export class AppStore { bridgeStatuses = new Map() peripherals = new Map() + undoLedgers: SerializableLedgers = {} + undoLedgerCurrentKey: string = SpecialLedgers.APPLICATION serverAPI: ApiClient logger: ClientSideLogger @@ -30,6 +33,7 @@ export class AppStore { this.updateBridgeStatus(bridgeId, status), updatePeripheral: (peripheralId: BridgePeripheralId, peripheral: PeripheralStatus | null) => this.updatePeripheral(peripheralId, peripheral), + updateUndoLedgers: (data: SerializableLedgers) => this.updateUndoLedgers(data), }) makeAutoObservable(this) @@ -69,6 +73,10 @@ export class AppStore { } } + updateUndoLedgers(data: SerializableLedgers): void { + this.undoLedgers = data + } + private _updateAllDeviceStatuses() { for (const bridgeStatus of this.bridgeStatuses.values()) { for (const [deviceId, deviceStatus] of Object.entries(bridgeStatus.devices)) { diff --git a/apps/app/src/react/mobx/GDDValidatorStoreStore.ts b/apps/app/src/react/mobx/GDDValidatorStoreStore.ts index 2c95d336..cee7d1bd 100644 --- a/apps/app/src/react/mobx/GDDValidatorStoreStore.ts +++ b/apps/app/src/react/mobx/GDDValidatorStoreStore.ts @@ -1,7 +1,6 @@ import { makeAutoObservable, runInAction } from 'mobx' import { SchemaValidator, setupSchemaValidator, ValidatorCache } from 'graphics-data-definition' import { ApiClient } from '../api/ApiClient' -// const { ipcRenderer } = window.require('electron') export class GDDValidatorStore { private isInitialized = false diff --git a/apps/app/src/react/mobx/GroupPlayDataStore.ts b/apps/app/src/react/mobx/GroupPlayDataStore.ts index 4399deab..c2c43449 100644 --- a/apps/app/src/react/mobx/GroupPlayDataStore.ts +++ b/apps/app/src/react/mobx/GroupPlayDataStore.ts @@ -5,7 +5,6 @@ import { RealtimeDataProvider } from '../api/RealtimeDataProvider' import { Rundown } from '../../models/rundown/Rundown' import { ApiClient } from '../api/ApiClient' import { ClientSideLogger } from '../api/logger' -// const { ipcRenderer } = window.require('electron') export class GroupPlayDataStore { groups: Map = new Map() diff --git a/apps/app/src/react/mobx/GuiStore.ts b/apps/app/src/react/mobx/GuiStore.ts index b9610caf..5aebab84 100644 --- a/apps/app/src/react/mobx/GuiStore.ts +++ b/apps/app/src/react/mobx/GuiStore.ts @@ -10,7 +10,6 @@ import { } from '../../lib/GUI' import { DefiningArea } from '../../lib/triggers/keyDisplay/keyDisplay' import { ApiClient } from '../api/ApiClient' -// const { ipcRenderer } = window.require('electron') /** * Store contains only information about user interface @@ -91,6 +90,8 @@ export class GuiStore { this._activeTabId = id } + public isPeripheralPopoverOpen = false + /** A list of all selected items */ get selected(): Readonly { return this._selected diff --git a/apps/app/src/react/mobx/ResourcesAndMetadataStore.ts b/apps/app/src/react/mobx/ResourcesAndMetadataStore.ts index 4102380f..e51ab8a2 100644 --- a/apps/app/src/react/mobx/ResourcesAndMetadataStore.ts +++ b/apps/app/src/react/mobx/ResourcesAndMetadataStore.ts @@ -1,6 +1,5 @@ import { makeAutoObservable } from 'mobx' import { ApiClient } from '../api/ApiClient' -// const { ipcRenderer } = window.require('electron') import { RealtimeDataProvider } from '../api/RealtimeDataProvider' import { MetadataAny, diff --git a/yarn.lock b/yarn.lock index 9c970e7a..ef54bcc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6433,6 +6433,11 @@ eventemitter3@^4.0.0, eventemitter3@^4.0.4, eventemitter3@^4.0.7: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +eventemitter3@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" + integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" From 10678b567bce5cca2aff0802d5fcfd799dff8945 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 31 Oct 2023 11:17:12 +0100 Subject: [PATCH 4/6] chore: remove completed todos --- apps/app/src/electron/ClientEventBus.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/app/src/electron/ClientEventBus.ts b/apps/app/src/electron/ClientEventBus.ts index d639f39b..e244a150 100644 --- a/apps/app/src/electron/ClientEventBus.ts +++ b/apps/app/src/electron/ClientEventBus.ts @@ -34,13 +34,13 @@ export class ClientEventBus extends EventEmitter implement this.emit('callMethod', 'updateAppData', appData) } updateProject(project: Project): void { - this.emit('updateProject', project) // TODO: some type safety, please + this.emit('updateProject', project) } updateRundown(_fileName: string, rundown: Rundown): void { - this.emit('updateRundown', rundown) // TODO: some type safety, please + this.emit('updateRundown', rundown) } updateUndoLedgers(data: SerializableLedgers): void { - this.emit('updateUndoLedgers', data) // TODO: some type safety, please + this.emit('updateUndoLedgers', data) } updateResourcesAndMetadata( resources: Array<{ id: ResourceId; resource: ResourceAny | null }>, From b4219aa39a6911d7b2c8e2f0caae1856cca3741c Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 31 Oct 2023 11:19:02 +0100 Subject: [PATCH 5/6] fix: make setting part trigger undoable --- apps/app/src/electron/EverythingService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index 83fce8ad..10abf2f9 100644 --- a/apps/app/src/electron/EverythingService.ts +++ b/apps/app/src/electron/EverythingService.ts @@ -405,6 +405,7 @@ export class EverythingService implements ConvertToServerSide return rundown } + @Undoable async setPartTrigger(arg: { rundownId: string groupId: string From 40be119dbd176111ec3e818251a184797ae1f19d Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 31 Oct 2023 11:22:16 +0100 Subject: [PATCH 6/6] chore: remove commented out code --- apps/app/src/electron/EverythingService.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index 10abf2f9..4b9aed01 100644 --- a/apps/app/src/electron/EverythingService.ts +++ b/apps/app/src/electron/EverythingService.ts @@ -83,13 +83,6 @@ import { SuperConductor } from './SuperConductor' import { UndoLedgerKey, UndoLedgerService } from './UndoService' import { SpecialLedgers } from '../models/project/Project' -// type IPCServerEvents = { -// updatedUndoLedger: ( -// undoLedger: Readonly<{ [key: string | symbol]: UndoLedger }>, -// undoPointer: Readonly -// ) => void -// } - export function isUndoable(result: unknown): result is UndoableResult { if (typeof result !== 'object' || result === null) { return false