diff --git a/src/com/Communication.ts b/src/com/Communication.ts index c1b8692c..241c4e2d 100644 --- a/src/com/Communication.ts +++ b/src/com/Communication.ts @@ -1,12 +1,13 @@ import { Color, MeshStandardMaterial, MathUtils } from "three"; import DIVEScene from "../scene/Scene.ts"; -import { Actions } from "./actions/index.ts"; +import { InternalActionOptions, Actions, ActionOptions } from "./actions/index.ts"; import { COMLight, COMModel, COMEntity, COMPov } from "./types.ts"; import DIVEToolbox from "../toolbox/Toolbox.ts"; import DIVEMediaCreator from "../mediacreator/MediaCreator.ts"; import DIVEOrbitControls from "../controls/OrbitControls.ts"; import { DIVESelectable } from "../interface/Selectable.ts"; import DIVESelectTool from "../toolbox/select/SelectTool.ts"; +import { isMac, isWindows } from "../helper/platform/platform.ts"; type EventListener = (payload: Actions[Action]['PAYLOAD']) => void; @@ -39,17 +40,25 @@ export default class DIVECommunication { return this.__instances.find((instance) => Array.from(instance.registered.values()).find((object) => object.id === id)); } + // general references private id: string; private scene: DIVEScene; private controller: DIVEOrbitControls; private toolbox: DIVEToolbox; private mediaGenerator: DIVEMediaCreator; + // all registered data private registered: Map = new Map(); - // private listeners: { [key: string]: EventListener[] } = {}; + // subscribe listeners private listeners: Map[]> = new Map(); + // undo: stores the actions that are used to undo a certain action + private undoStack: { action: keyof Actions, payload: Actions[keyof Actions]['PAYLOAD'] }[] = []; + + // redo: stores the actions that are used to redo a certain action + private redoStack: { action: keyof Actions, payload: Actions[keyof Actions]['PAYLOAD'] }[] = []; + constructor(scene: DIVEScene, controls: DIVEOrbitControls, toolbox: DIVEToolbox, mediaGenerator: DIVEMediaCreator) { this.id = MathUtils.generateUUID(); this.scene = scene; @@ -57,6 +66,20 @@ export default class DIVECommunication { this.toolbox = toolbox; this.mediaGenerator = mediaGenerator; + window.addEventListener('keydown', (event) => { + if (isMac()) { + if (event.metaKey && event.key === 'z') { + event.shiftKey ? this.Redo() : this.Undo(); + } + } + + if (isWindows()) { + if (event.ctrlKey && event.key === 'z') { + event.shiftKey ? this.Redo() : this.Undo(); + } + } + }) + DIVECommunication.__instances.push(this); } @@ -67,84 +90,89 @@ export default class DIVECommunication { return true; } - public PerformAction(action: Action, payload: Actions[Action]['PAYLOAD']): Actions[Action]['RETURN'] { + public PerformAction(action: Action, payload: Actions[Action]['PAYLOAD'], options?: ActionOptions): Actions[Action]['RETURN'] { + this.redoStack = []; + return this.internal_perform(action, payload, options); + } + + public internal_perform(action: Action, payload: Actions[Action]['PAYLOAD'], options?: InternalActionOptions): Actions[Action]['RETURN'] { let returnValue: Actions[Action]['RETURN'] = false; switch (action) { case 'GET_ALL_SCENE_DATA': { - returnValue = this.getAllSceneData(payload as Actions['GET_ALL_SCENE_DATA']['PAYLOAD']); + returnValue = this.getAllSceneData(payload as Actions['GET_ALL_SCENE_DATA']['PAYLOAD'], options); break; } case 'GET_ALL_OBJECTS': { - returnValue = this.getAllObjects(payload as Actions['GET_ALL_OBJECTS']['PAYLOAD']); + returnValue = this.getAllObjects(payload as Actions['GET_ALL_OBJECTS']['PAYLOAD'], options); break; } case 'GET_OBJECTS': { - returnValue = this.getObjects(payload as Actions['GET_OBJECTS']['PAYLOAD']); + returnValue = this.getObjects(payload as Actions['GET_OBJECTS']['PAYLOAD'], options); break; } case 'ADD_OBJECT': { - returnValue = this.addObject(payload as Actions['ADD_OBJECT']['PAYLOAD']); + returnValue = this.addObject(payload as Actions['ADD_OBJECT']['PAYLOAD'], options); break; } case 'UPDATE_OBJECT': { - returnValue = this.updateObject(payload as Actions['UPDATE_OBJECT']['PAYLOAD']); + returnValue = this.updateObject(payload as Actions['UPDATE_OBJECT']['PAYLOAD'], options); break; } case 'DELETE_OBJECT': { - returnValue = this.deleteObject(payload as Actions['DELETE_OBJECT']['PAYLOAD']); + returnValue = this.deleteObject(payload as Actions['DELETE_OBJECT']['PAYLOAD'], options); break; } case 'SELECT_OBJECT': { - returnValue = this.selectObject(payload as Actions['SELECT_OBJECT']['PAYLOAD']); + returnValue = this.selectObject(payload as Actions['SELECT_OBJECT']['PAYLOAD'], options); break; } case 'SET_BACKGROUND': { - returnValue = this.setBackground(payload as Actions['SET_BACKGROUND']['PAYLOAD']); + returnValue = this.setBackground(payload as Actions['SET_BACKGROUND']['PAYLOAD'], options); break; } case 'PLACE_ON_FLOOR': { - returnValue = this.placeOnFloor(payload as Actions['PLACE_ON_FLOOR']['PAYLOAD']); + returnValue = this.placeOnFloor(payload as Actions['PLACE_ON_FLOOR']['PAYLOAD'], options); break; } case 'SET_CAMERA_TRANSFORM': { - returnValue = this.setCameraTransform(payload as Actions['SET_CAMERA_TRANSFORM']['PAYLOAD']); + returnValue = this.setCameraTransform(payload as Actions['SET_CAMERA_TRANSFORM']['PAYLOAD'], options); break; } case 'GET_CAMERA_TRANSFORM': { - returnValue = this.getCameraTransform(payload as Actions['GET_CAMERA_TRANSFORM']['PAYLOAD']); + returnValue = this.getCameraTransform(payload as Actions['GET_CAMERA_TRANSFORM']['PAYLOAD'], options); break; } case 'MOVE_CAMERA': { - returnValue = this.moveCamera(payload as Actions['MOVE_CAMERA']['PAYLOAD']); + returnValue = this.moveCamera(payload as Actions['MOVE_CAMERA']['PAYLOAD'], options); break; } case 'RESET_CAMERA': { - returnValue = this.resetCamera(payload as Actions['RESET_CAMERA']['PAYLOAD']); + returnValue = this.resetCamera(payload as Actions['RESET_CAMERA']['PAYLOAD'], options); break; } case 'SET_CAMERA_LAYER': { - returnValue = this.setCameraLayer(payload as Actions['SET_CAMERA_LAYER']['PAYLOAD']); + returnValue = this.setCameraLayer(payload as Actions['SET_CAMERA_LAYER']['PAYLOAD'], options); break; } case 'ZOOM_CAMERA': { - returnValue = this.zoomCamera(payload as Actions['ZOOM_CAMERA']['PAYLOAD']); + returnValue = this.zoomCamera(payload as Actions['ZOOM_CAMERA']['PAYLOAD'], options); break; } case 'SET_GIZMO_MODE': { - returnValue = this.setGizmoMode(payload as Actions['SET_GIZMO_MODE']['PAYLOAD']); + returnValue = this.setGizmoMode(payload as Actions['SET_GIZMO_MODE']['PAYLOAD'], options); break; } case 'MODEL_LOADED': { - returnValue = this.modelLoaded(payload as Actions['MODEL_LOADED']['PAYLOAD']); + returnValue = this.modelLoaded(payload as Actions['MODEL_LOADED']['PAYLOAD'], options); break; } case 'UPDATE_SCENE': { - returnValue = this.updateScene(payload as Actions['UPDATE_SCENE']['PAYLOAD']); + returnValue = this.updateScene(payload as Actions['UPDATE_SCENE']['PAYLOAD'], options); break; } case 'GENERATE_MEDIA': { - returnValue = this.generateMedia(payload as Actions['GENERATE_MEDIA']['PAYLOAD']); + returnValue = this.generateMedia(payload as Actions['GENERATE_MEDIA']['PAYLOAD'], options); break; } } @@ -154,6 +182,20 @@ export default class DIVECommunication { return returnValue; } + public Undo(): void { + const undoAction = this.undoStack.pop(); + if (!undoAction) return; + + this.internal_perform(undoAction.action, undoAction.payload, { redoable: true }); + } + + public Redo(): void { + const redoAction = this.redoStack.pop(); + if (!redoAction) return; + + this.internal_perform(redoAction.action, redoAction.payload, { undoable: true }); + } + public Subscribe(type: Action, listener: EventListener): Unsubscribe { if (!this.listeners.get(type)) this.listeners.set(type, []); @@ -179,7 +221,7 @@ export default class DIVECommunication { listenerArray.forEach((listener) => listener(payload)) } - private getAllSceneData(payload: Actions['GET_ALL_SCENE_DATA']['PAYLOAD']): Actions['GET_ALL_SCENE_DATA']['RETURN'] { + private getAllSceneData(payload: Actions['GET_ALL_SCENE_DATA']['PAYLOAD'], options?: InternalActionOptions): Actions['GET_ALL_SCENE_DATA']['RETURN'] { const sceneData = { name: this.scene.name, mediaItem: null, @@ -199,12 +241,12 @@ export default class DIVECommunication { return sceneData; } - private getAllObjects(payload: Actions['GET_ALL_OBJECTS']['PAYLOAD']): Actions['GET_ALL_OBJECTS']['RETURN'] { + private getAllObjects(payload: Actions['GET_ALL_OBJECTS']['PAYLOAD'], options?: InternalActionOptions): Actions['GET_ALL_OBJECTS']['RETURN'] { Object.assign(payload, this.registered); return this.registered; } - private getObjects(payload: Actions['GET_OBJECTS']['PAYLOAD']): Actions['GET_OBJECTS']['RETURN'] { + private getObjects(payload: Actions['GET_OBJECTS']['PAYLOAD'], options?: InternalActionOptions): Actions['GET_OBJECTS']['RETURN'] { this.registered.forEach((object) => { if (payload.ids && payload.ids.length > 0 && !payload.ids.includes(object.id)) return; payload.map.set(object.id, object); @@ -213,9 +255,27 @@ export default class DIVECommunication { return payload.map; } - private addObject(payload: Actions['ADD_OBJECT']['PAYLOAD']): Actions['ADD_OBJECT']['RETURN'] { + private addObject(payload: Actions['ADD_OBJECT']['PAYLOAD'], options?: InternalActionOptions): Actions['ADD_OBJECT']['RETURN'] { if (this.registered.get(payload.id)) return false; + if (options?.undoable) { + this.undoStack.push({ + action: 'DELETE_OBJECT', + payload: { + id: payload.id, + }, + }); + } + + if (options?.redoable) { + this.redoStack.push({ + action: 'DELETE_OBJECT', + payload: { + id: payload.id, + }, + }); + } + this.registered.set(payload.id, payload); this.scene.AddSceneObject(payload); @@ -223,10 +283,28 @@ export default class DIVECommunication { return true; } - private updateObject(payload: Actions['UPDATE_OBJECT']['PAYLOAD']): Actions['UPDATE_OBJECT']['RETURN'] { + private updateObject(payload: Actions['UPDATE_OBJECT']['PAYLOAD'], options?: InternalActionOptions): Actions['UPDATE_OBJECT']['RETURN'] { const objectToUpdate = this.registered.get(payload.id); if (!objectToUpdate) return false; + if (options?.undoable) { + this.undoStack.push({ + action: 'UPDATE_OBJECT', + payload: { + ...objectToUpdate, + }, + }); + } + + if (options?.redoable) { + this.redoStack.push({ + action: 'UPDATE_OBJECT', + payload: { + ...objectToUpdate, + }, + }); + } + this.registered.set(payload.id, { ...objectToUpdate, ...payload }); const updatedObject = this.registered.get(payload.id)!; @@ -237,10 +315,28 @@ export default class DIVECommunication { return true; } - private deleteObject(payload: Actions['DELETE_OBJECT']['PAYLOAD']): Actions['DELETE_OBJECT']['RETURN'] { + private deleteObject(payload: Actions['DELETE_OBJECT']['PAYLOAD'], options?: InternalActionOptions): Actions['DELETE_OBJECT']['RETURN'] { const deletedObject = this.registered.get(payload.id); if (!deletedObject) return false; + if (options?.undoable) { + this.undoStack.push({ + action: 'ADD_OBJECT', + payload: { + ...deletedObject, + }, + }); + } + + if (options?.redoable) { + this.redoStack.push({ + action: 'ADD_OBJECT', + payload: { + ...deletedObject, + }, + }); + } + // copy object to payload to use later Object.assign(payload, deletedObject); @@ -251,7 +347,7 @@ export default class DIVECommunication { return true; } - private selectObject(payload: Actions['SELECT_OBJECT']['PAYLOAD']): Actions['SELECT_OBJECT']['RETURN'] { + private selectObject(payload: Actions['SELECT_OBJECT']['PAYLOAD'], options?: InternalActionOptions): Actions['SELECT_OBJECT']['RETURN'] { const object = this.registered.get(payload.id); if (!object) return false; @@ -269,21 +365,58 @@ export default class DIVECommunication { return true; } - private setBackground(payload: Actions['SET_BACKGROUND']['PAYLOAD']): Actions['SET_BACKGROUND']['RETURN'] { + private setBackground(payload: Actions['SET_BACKGROUND']['PAYLOAD'], options?: InternalActionOptions): Actions['SET_BACKGROUND']['RETURN'] { + if (options?.undoable) { + this.undoStack.push({ + action: 'SET_BACKGROUND', + payload: { + color: this.scene.GetBackground(), + }, + }); + } + + if (options?.redoable) { + this.redoStack.push({ + action: 'SET_BACKGROUND', + payload: { + color: this.scene.GetBackground(), + }, + }); + } + this.scene.SetBackground(payload.color); return true; } - private placeOnFloor(payload: Actions['PLACE_ON_FLOOR']['PAYLOAD']): Actions['PLACE_ON_FLOOR']['RETURN'] { - if (!this.registered.get(payload.id)) return false; + private placeOnFloor(payload: Actions['PLACE_ON_FLOOR']['PAYLOAD'], options?: InternalActionOptions): Actions['PLACE_ON_FLOOR']['RETURN'] { + const objectToPlaceOnFloor = this.registered.get(payload.id); + if (!objectToPlaceOnFloor) return false; + + if (options?.undoable) { + this.undoStack.push({ + action: 'UPDATE_OBJECT', + payload: { + ...objectToPlaceOnFloor, + }, + }); + } + + if (options?.redoable) { + this.redoStack.push({ + action: 'UPDATE_OBJECT', + payload: { + ...objectToPlaceOnFloor, + }, + }); + } this.scene.PlaceOnFloor(payload); return true; } - private setCameraTransform(payload: Actions['SET_CAMERA_TRANSFORM']['PAYLOAD']): Actions['SET_CAMERA_TRANSFORM']['RETURN'] { + private setCameraTransform(payload: Actions['SET_CAMERA_TRANSFORM']['PAYLOAD'], options?: InternalActionOptions): Actions['SET_CAMERA_TRANSFORM']['RETURN'] { this.controller.object.position.copy(payload.position); this.controller.target.copy(payload.target); this.controller.update(); @@ -291,7 +424,7 @@ export default class DIVECommunication { return true; } - private getCameraTransform(payload: Actions['GET_CAMERA_TRANSFORM']['PAYLOAD']): Actions['GET_CAMERA_TRANSFORM']['RETURN'] { + private getCameraTransform(payload: Actions['GET_CAMERA_TRANSFORM']['PAYLOAD'], options?: InternalActionOptions): Actions['GET_CAMERA_TRANSFORM']['RETURN'] { const transform = { position: this.controller.object.position.clone(), target: this.controller.target.clone() @@ -301,7 +434,7 @@ export default class DIVECommunication { return transform; } - private moveCamera(payload: Actions['MOVE_CAMERA']['PAYLOAD']): Actions['MOVE_CAMERA']['RETURN'] { + private moveCamera(payload: Actions['MOVE_CAMERA']['PAYLOAD'], options?: InternalActionOptions): Actions['MOVE_CAMERA']['RETURN'] { let position = { x: 0, y: 0, z: 0 }; let target = { x: 0, y: 0, z: 0 }; if ('id' in payload) { @@ -316,36 +449,60 @@ export default class DIVECommunication { return true; } - private setCameraLayer(payload: Actions['SET_CAMERA_LAYER']['PAYLOAD']): Actions['SET_CAMERA_LAYER']['RETURN'] { + private setCameraLayer(payload: Actions['SET_CAMERA_LAYER']['PAYLOAD'], options?: InternalActionOptions): Actions['SET_CAMERA_LAYER']['RETURN'] { this.controller.object.SetCameraLayer(payload.layer); return true; } - private resetCamera(payload: Actions['RESET_CAMERA']['PAYLOAD']): Actions['RESET_CAMERA']['RETURN'] { + private resetCamera(payload: Actions['RESET_CAMERA']['PAYLOAD'], options?: InternalActionOptions): Actions['RESET_CAMERA']['RETURN'] { this.controller.RevertLast(payload.duration); return true; } - private zoomCamera(payload: Actions['ZOOM_CAMERA']['PAYLOAD']): Actions['ZOOM_CAMERA']['RETURN'] { + private zoomCamera(payload: Actions['ZOOM_CAMERA']['PAYLOAD'], options?: InternalActionOptions): Actions['ZOOM_CAMERA']['RETURN'] { if (payload.direction === 'IN') this.controller.ZoomIn(payload.by); if (payload.direction === 'OUT') this.controller.ZoomOut(payload.by); return true; } - private setGizmoMode(payload: Actions['SET_GIZMO_MODE']['PAYLOAD']): Actions['SET_GIZMO_MODE']['RETURN'] { + private setGizmoMode(payload: Actions['SET_GIZMO_MODE']['PAYLOAD'], options?: InternalActionOptions): Actions['SET_GIZMO_MODE']['RETURN'] { this.toolbox.SetGizmoMode(payload.mode); return true; } - private modelLoaded(payload: Actions['MODEL_LOADED']['PAYLOAD']): Actions['MODEL_LOADED']['RETURN'] { + private modelLoaded(payload: Actions['MODEL_LOADED']['PAYLOAD'], options?: InternalActionOptions): Actions['MODEL_LOADED']['RETURN'] { (this.registered.get(payload.id) as COMModel).loaded = true; return true; } - private updateScene(payload: Actions['UPDATE_SCENE']['PAYLOAD']): Actions['UPDATE_SCENE']['RETURN'] { + private updateScene(payload: Actions['UPDATE_SCENE']['PAYLOAD'], options?: InternalActionOptions): Actions['UPDATE_SCENE']['RETURN'] { + if (options?.undoable) { + this.undoStack.push({ + action: 'UPDATE_SCENE', + payload: { + name: this.scene.name, + backgroundColor: this.scene.GetBackground(), + floorEnabled: this.scene.Root.Floor.visible, + floorColor: '#' + (this.scene.Root.Floor.material as MeshStandardMaterial).color.getHexString(), + }, + }); + } + + if (options?.redoable) { + this.redoStack.push({ + action: 'UPDATE_SCENE', + payload: { + name: this.scene.name, + backgroundColor: this.scene.GetBackground(), + floorEnabled: this.scene.Root.Floor.visible, + floorColor: '#' + (this.scene.Root.Floor.material as MeshStandardMaterial).color.getHexString(), + }, + }); + } + if (payload.name !== undefined) this.scene.name = payload.name; if (payload.backgroundColor !== undefined) this.scene.SetBackground(payload.backgroundColor); @@ -363,7 +520,7 @@ export default class DIVECommunication { return true; } - private generateMedia(payload: Actions['GENERATE_MEDIA']['PAYLOAD']): Actions['GENERATE_MEDIA']['RETURN'] { + private generateMedia(payload: Actions['GENERATE_MEDIA']['PAYLOAD'], options?: InternalActionOptions): Actions['GENERATE_MEDIA']['RETURN'] { let position = { x: 0, y: 0, z: 0 }; let target = { x: 0, y: 0, z: 0 }; if ('id' in payload) { diff --git a/src/com/actions/index.ts b/src/com/actions/index.ts index b3dd55d8..b757f1a4 100644 --- a/src/com/actions/index.ts +++ b/src/com/actions/index.ts @@ -18,6 +18,14 @@ import GET_ALL_SCENE_DATA from "./scene/getallscenedata.ts"; import SELECT_OBJECT from "./object/selectobject.ts"; import GET_CAMERA_TRANSFORM from "./camera/getcameratransform.ts"; +export type ActionOptions = { + undoable?: boolean, +} + +export type InternalActionOptions = { + redoable?: boolean, +} & ActionOptions; + export type Actions = { GET_ALL_SCENE_DATA: GET_ALL_SCENE_DATA, GET_ALL_OBJECTS: GET_ALL_OBJECTS,