From aa924a3794f9767ad495fd5a38d4a4c2c02b7c19 Mon Sep 17 00:00:00 2001 From: Michael Chung Date: Wed, 7 May 2025 21:01:58 -0400 Subject: [PATCH] Resolved more issues and deleted a bunch of things not needed. --- .../editor3d/app/src/pages/Page2d/Engine.ts | 738 +++++++++++++++ .../src/pages/Page2d/KonvaApp/EditEngine.ts | 371 ++++++++ .../EngineUtitlities/Base64Helpers.ts | 7 + .../EngineUtitlities/CommandManager.ts | 173 ++++ .../KonvaApp/EngineUtitlities/MatteBox.ts | 134 +++ .../KonvaApp/EngineUtitlities/SceneManager.ts | 229 +++++ .../Page2d/KonvaApp/EngineUtitlities/index.ts | 3 + .../pages/Page2d/KonvaApp/GenerationEngine.ts | 46 + .../pages/Page2d/KonvaApp/Nodes/BaseNode.ts | 253 +++++ .../pages/Page2d/KonvaApp/Nodes/ImageNode.ts | 261 +++++ .../Page2d/KonvaApp/Nodes/NetworkedNode.ts | 134 +++ .../Page2d/KonvaApp/Nodes/NodeUtilities.ts | 158 ++++ .../pages/Page2d/KonvaApp/Nodes/PaintNode.ts | 91 ++ .../Page2d/KonvaApp/Nodes/PreviewCopy.ts | 58 ++ .../Page2d/KonvaApp/Nodes/PureTextNode.ts | 186 ++++ .../pages/Page2d/KonvaApp/Nodes/ShapeNode.ts | 152 +++ .../pages/Page2d/KonvaApp/Nodes/TextNode.ts | 168 ++++ .../pages/Page2d/KonvaApp/Nodes/constants.ts | 16 + .../src/pages/Page2d/KonvaApp/Nodes/index.ts | 7 + .../KonvaApp/NodesManagers/NodeIsolator.ts | 122 +++ .../KonvaApp/NodesManagers/NodeTransformer.ts | 61 ++ .../KonvaApp/NodesManagers/NodesManager.ts | 63 ++ .../NodesManagers/SelectionManager.ts | 397 ++++++++ .../KonvaApp/NodesManagers/SelectorSquare.ts | 176 ++++ .../NodesManagers/ToolbarNodeUtilities.ts | 236 +++++ .../Page2d/KonvaApp/NodesManagers/index.ts | 10 + .../OffScreenSceneCanvas.ts | 59 ++ .../OffScreenSceneContext.ts | 11 + .../RenderingPrimitives/RealTimeDrawEngine.ts | 892 ++++++++++++++++++ .../RenderingPrimitives/RenderTask.ts | 37 + .../KonvaApp/RenderingPrimitives/note.txt | 18 + .../Page2d/KonvaApp/UndoRedo/CreateCommand.ts | 77 ++ .../Page2d/KonvaApp/UndoRedo/DeleteCommand.ts | 77 ++ .../Page2d/KonvaApp/UndoRedo/ICommand.ts | 4 + .../KonvaApp/UndoRedo/LockNodesCommand.ts | 19 + .../Page2d/KonvaApp/UndoRedo/MoveLayerDown.ts | 64 ++ .../Page2d/KonvaApp/UndoRedo/MoveLayerUp.ts | 64 ++ .../KonvaApp/UndoRedo/TransformCommand.ts | 82 ++ .../KonvaApp/UndoRedo/TranslateCommand.ts | 46 + .../KonvaApp/UndoRedo/UndoRedoManager.ts | 79 ++ .../KonvaApp/UndoRedo/UnlockNodesCommand.ts | 19 + .../pages/Page2d/KonvaApp/UndoRedo/index.ts | 9 + .../src/pages/Page2d/KonvaApp/constants.ts | 25 + .../app/src/pages/Page2d/KonvaApp/index.ts | 11 + .../pages/Page2d/KonvaApp/types/NodeData.ts | 42 + .../src/pages/Page2d/KonvaApp/types/Text.ts | 32 + .../Page2d/KonvaApp/types/Transformation.ts | 16 + .../Page2d/KonvaApp/types/basicValueTypes.ts | 14 + .../src/pages/Page2d/KonvaApp/types/events.ts | 22 + .../src/pages/Page2d/KonvaApp/types/index.ts | 10 + .../src/pages/Page2d/KonvaCanvasContainer.tsx | 4 +- .../src/pages/Page2d/KonvaRootComponent.tsx | 10 +- .../app/src/pages/Page2d/SignaledDialogs.tsx | 4 +- .../src/pages/Page2d/SignaledToolbarMain.tsx | 97 ++ .../DialogAddImage/DialogAddImage.tsx | 76 ++ .../features/DialogAddImage/index.tsx | 1 + .../DialogEditText/DialogEditText.tsx | 117 +++ .../features/DialogEditText/index.ts | 1 + .../components/features/DialogError/index.tsx | 36 + .../components/features/ToolbarButton.tsx | 90 ++ .../ToolbarMain/ButtonPreviewAndRender.tsx | 74 ++ .../features/ToolbarMain/ToolbarMain.tsx | 267 ++++++ .../components/features/ToolbarMain/enum.ts | 22 + .../components/features/ToolbarMain/index.tsx | 1 + .../features/ToolbarNode/ToolbarNode.tsx | 8 +- .../pages/Page2d/components/features/index.ts | 5 + .../reusable/FileUploader/DragAndDropZone.tsx | 52 + .../reusable/FileUploader/FileUploader.tsx | 23 + .../components/reusable/FileUploader/index.ts | 1 + .../components/ui/Activity/Activity.tsx | 179 ++++ .../components/ui/Activity/CompletedCard.tsx | 91 ++ .../components/ui/Activity/InProgressCard.tsx | 78 ++ .../Page2d/components/ui/Activity/index.ts | 1 + .../pages/Page2d/components/ui/BaseDialog.tsx | 65 ++ .../src/pages/Page2d/components/ui/Button.tsx | 112 +++ .../pages/Page2d/components/ui/ComboBox.tsx | 58 ++ .../pages/Page2d/components/ui/Gravatar.tsx | 106 +++ .../Page2d/components/ui/HexEyedropPicker.tsx | 85 ++ .../src/pages/Page2d/components/ui/Input.tsx | 86 ++ .../Page2d/components/ui/InputNumber.tsx | 81 ++ .../Page2d/components/ui/LabeledSelect.tsx | 68 ++ .../pages/Page2d/components/ui/LoadingBar.tsx | 65 ++ .../Page2d/components/ui/LoadingSpinner.tsx | 33 + .../Page2d/components/ui/PanelBottom.tsx | 69 ++ .../pages/Page2d/components/ui/PanelRight.tsx | 66 ++ .../src/pages/Page2d/components/ui/Slider.tsx | 184 ++++ .../pages/Page2d/components/ui/Spinner.tsx | 14 + .../pages/Page2d/components/ui/Switches.tsx | 49 + .../Page2d/components/ui/TabSelector.tsx | 107 +++ .../pages/Page2d/components/ui/TextArea.tsx | 99 ++ .../ui/TextEditor/ButtonTextStyles.tsx | 87 ++ .../ui/TextEditor/ButtonsAlignment.tsx | 44 + .../components/ui/TextEditor/ColorPicker.tsx | 183 ++++ .../components/ui/TextEditor/TextEditor.tsx | 161 ++++ .../Page2d/components/ui/TextEditor/index.ts | 1 + .../Page2d/components/ui/TextEditor/styles.ts | 4 + .../Page2d/components/ui/TextEditor/types.ts | 5 + .../Page2d/components/ui/ToggleButton.tsx | 41 + .../ui/ToolbarMain/BackgroundMenu.tsx | 183 ++++ .../ui/ToolbarMain/EraseModeMenu.tsx | 102 ++ .../ui/ToolbarMain/PaintModeMenu.tsx | 253 +++++ .../pages/Page2d/components/ui/Tooltip.tsx | 27 + .../src/pages/Page2d/components/ui/index.ts | 14 + .../pages/Page2d/constants/fileTypeEnums.ts | 10 + .../src/pages/Page2d/contextSignals/appUi.ts | 68 ++ .../src/pages/Page2d/contextSignals/layout.ts | 38 + .../signals/authentication/auth_signals.ts | 87 +- .../Page2d/signals/canvasRenderBitmap.tsx | 15 + .../pages/Page2d/signals/uiAccess/index.ts | 3 +- .../Page2d/signals/uiAccess/loadingBar.ts | 2 +- .../uiAccess/toolbarMain/loadingBar.ts | 2 +- .../uiAccess/toolbarMain/toolbarMain.ts | 4 +- .../Page2d/signals/uiAccess/toolbarNode.ts | 2 +- .../uiAccess/toolbarVideoExtraction.ts | 96 -- .../Page2d/signals/uiEvents/aiStylize.ts | 59 -- .../pages/Page2d/signals/uiEvents/index.ts | 11 +- .../Page2d/signals/uiEvents/modelSelection.ts | 52 +- .../uiEvents/toolbarMain/toolbarMain.ts | 4 +- .../Page2d/signals/uiEvents/toolbarNode.ts | 2 +- .../src/pages/Page2d/useRenderCounter copy.ts | 14 + .../Page2d/utilities/DecodeBase64ToImage.ts | 30 + .../utilities/EncodeImageBitmapToBase64.ts | 22 + .../pages/Page2d/utilities/FileUtilities.ts | 48 + .../Page2d/utilities/SaveImageBitmapToFile.ts | 19 + .../app/src/pages/Page2d/utilities/index.ts | 18 + .../app/src/signals/canvasRenderBitmap.tsx | 15 + 126 files changed, 10049 insertions(+), 241 deletions(-) create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/Engine.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EditEngine.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/Base64Helpers.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/CommandManager.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/MatteBox.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/SceneManager.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/GenerationEngine.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/BaseNode.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/ImageNode.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/NetworkedNode.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/NodeUtilities.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PaintNode.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PreviewCopy.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PureTextNode.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/ShapeNode.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/TextNode.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/constants.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodeIsolator.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodeTransformer.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodesManager.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/SelectionManager.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/SelectorSquare.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/ToolbarNodeUtilities.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/OffScreenSceneCanvas.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/OffScreenSceneContext.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/RealTimeDrawEngine.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/RenderTask.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/note.txt create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/CreateCommand.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/DeleteCommand.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/ICommand.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/LockNodesCommand.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/MoveLayerDown.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/MoveLayerUp.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/TransformCommand.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/TranslateCommand.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/UndoRedoManager.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/UnlockNodesCommand.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/constants.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/NodeData.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/Text.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/Transformation.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/basicValueTypes.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/events.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/SignaledToolbarMain.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogAddImage/DialogAddImage.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogAddImage/index.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogEditText/DialogEditText.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogEditText/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogError/index.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarButton.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/ButtonPreviewAndRender.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/ToolbarMain.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/enum.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/index.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/features/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/DragAndDropZone.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/FileUploader.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/Activity.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/CompletedCard.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/InProgressCard.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/BaseDialog.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Button.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/ComboBox.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Gravatar.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/HexEyedropPicker.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Input.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/InputNumber.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LabeledSelect.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LoadingBar.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LoadingSpinner.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/PanelBottom.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/PanelRight.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Slider.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Spinner.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Switches.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TabSelector.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TextArea.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TextEditor/ButtonTextStyles.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TextEditor/ButtonsAlignment.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TextEditor/ColorPicker.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TextEditor/TextEditor.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TextEditor/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TextEditor/styles.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TextEditor/types.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/ToggleButton.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/ToolbarMain/BackgroundMenu.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/ToolbarMain/EraseModeMenu.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/ToolbarMain/PaintModeMenu.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Tooltip.tsx create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/components/ui/index.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/constants/fileTypeEnums.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/contextSignals/appUi.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/contextSignals/layout.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/signals/canvasRenderBitmap.tsx delete mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/signals/uiAccess/toolbarVideoExtraction.ts delete mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/signals/uiEvents/aiStylize.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/useRenderCounter copy.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/utilities/DecodeBase64ToImage.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/utilities/EncodeImageBitmapToBase64.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/utilities/FileUtilities.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/utilities/SaveImageBitmapToFile.ts create mode 100644 frontend/apps/editor3d/app/src/pages/Page2d/utilities/index.ts create mode 100644 frontend/apps/editor3d/app/src/signals/canvasRenderBitmap.tsx diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/Engine.ts b/frontend/apps/editor3d/app/src/pages/Page2d/Engine.ts new file mode 100644 index 0000000000..7b0f400821 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/Engine.ts @@ -0,0 +1,738 @@ +import Konva from "konva"; + +import { uiAccess, uiEvents } from "./signals"; +import { ImageNode, ShapeNode, ShapeType, TextNode, PaintNode, PreviewCopyNode } from "./KonvaApp/Nodes"; +import { UndoStackManager } from "./KonvaApp/UndoRedo"; +import { CommandManager, MatteBox, SceneManager } from "./KonvaApp/EngineUtitlities"; +import { + NodeIsolator, + NodesManager, + NodesTranslationEventDetails, + NodeTransformationEventDetails, + NodeTransformer, + SelectionManager, + SelectionManagerEvents, + SelectorSquare, +} from "./KonvaApp/NodesManagers"; +import { EngineOptions, TextNodeData, VideoNodeData } from "./KonvaApp/types"; + +import { AppModes, VideoResolutions } from "./KonvaApp/constants"; +import { ToolbarMainButtonNames } from "./components/features/ToolbarMain/enum"; + +import { ToolbarNodeButtonNames } from "./components/features/ToolbarNode/enums"; +import { NavigateFunction } from "react-router-dom"; + +import { RealTimeDrawEngine } from "./KonvaApp/RenderingPrimitives/RealTimeDrawEngine"; +import { NodeColor } from "./signals/uiEvents/toolbarNode"; +import { invoke } from "@tauri-apps/api/core"; + +import { DecodeBase64ToImage } from "./utilities/DecodeBase64ToImage"; +import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64"; + + + +export interface RenderingOptions { + artstyle: string; + positivePrompt: string; + negativePrompt: string; + cinematic: boolean; + enginePreProcessing: boolean; + faceDetail: boolean; + lipSync: boolean; + upscale: boolean; + styleStrength: number; +} + +export class Engine { + private navigateRef: NavigateFunction; + private appMode: AppModes = AppModes.INIT; + private boardCanvasRef: HTMLDivElement; + private stage: Konva.Stage; + private bgLayer: Konva.Layer; + private mediaLayer: Konva.Layer; + private nodeIsolationLayer: Konva.Layer; + private uiLayer: Konva.Layer; + private previewLayer: Konva.Layer; + private offScreenCanvas: OffscreenCanvas; + public realTimeDrawEngine: RealTimeDrawEngine; + private nodesManager: NodesManager; + private nodeIsolator: NodeIsolator; + private nodeTransformer: NodeTransformer; + private selectionManager: SelectionManager; + private selectorSquare: SelectorSquare; + private loadingVideosProvider: LoadingVideosProvider; + private matteBox: MatteBox; + private backgroundNode: ShapeNode | null = null; + + private sceneManager: SceneManager; + public undoStackManager: UndoStackManager; + public commandManager: CommandManager; + + public segmentationButtonCanBePressed: boolean = true; + // signal reference + constructor(boardCanvasRef: HTMLDivElement, options: EngineOptions) { + if (import.meta.env.DEV) { + console.log("Engine Created"); + } + this.navigateRef = options.navigate; + this.appMode = AppModes.SELECT; + + this.boardCanvasRef = boardCanvasRef; + this.stage = new Konva.Stage({ + container: this.boardCanvasRef, + width: window.innerWidth, + height: window.innerHeight, + }); + this.bgLayer = new Konva.Layer(); + + this.mediaLayer = new Konva.Layer(); + this.previewLayer = new Konva.Layer(); + + this.nodeIsolationLayer = new Konva.Layer(); + this.uiLayer = new Konva.Layer(); + + this.stage.add(this.bgLayer); + this.stage.add(this.previewLayer); + this.stage.add(this.mediaLayer); + this.stage.add(this.nodeIsolationLayer); + this.stage.add(this.uiLayer); + + // Konva Transformer + this.nodeTransformer = new NodeTransformer(); + this.uiLayer.add(this.nodeTransformer.getKonvaNode()); + // Loading Placeholders + + // Node Isolator + this.nodeIsolator = new NodeIsolator({ + mediaLayerRef: this.mediaLayer, + nodeIsolationLayerRef: this.nodeIsolationLayer, + }); + + // core layer for all the work done. + this.offScreenCanvas = new OffscreenCanvas(0, 0); + + // Collection of all Nodes + this.nodesManager = new NodesManager(); + // Partial Collection of selected Nodes + this.selectionManager = new SelectionManager({ + nodeTransformerRef: this.nodeTransformer, + mediaLayerRef: this.mediaLayer, + }); + + this.realTimeDrawEngine = new RealTimeDrawEngine({ + width: VideoResolutions.SQUARE_1024.width, + height: VideoResolutions.SQUARE_1024.height, + mediaLayerRef: this.mediaLayer, + offScreenCanvas: this.offScreenCanvas, + onDraw: async (canvas, lineBounds) => { + await this.addPaintNode(canvas, lineBounds); + }, + onPreviewCopy: async (image) => { + await this.addPreviewCopy(image); + }, + }); + + // Selector Square to select Nodes + this.selectorSquare = new SelectorSquare({ + captureCanvasRef: this.realTimeDrawEngine.captureCanvas, + mediaLayerRef: this.mediaLayer, + nodesManagerRef: this.nodesManager, + selectionManagerRef: this.selectionManager, + stageRef: this.stage, + }); + this.uiLayer.add(this.selectorSquare.getKonvaNode()); + + //Collection of commands for undo-redo + this.undoStackManager = new UndoStackManager(() => { + this.realTimeDrawEngine.render(); + }); + this.commandManager = new CommandManager({ + mediaLayerRef: this.mediaLayer, + nodesManagerRef: this.nodesManager, + nodeTransformerRef: this.nodeTransformer, + selectionManagerRef: this.selectionManager, + renderEngineRef: this.realTimeDrawEngine, + undoStackManagerRef: this.undoStackManager, + }); + + // set up secene manager + this.sceneManager = new SceneManager({ + navigateRef: this.navigateRef, + loadingVideosProviderRef: this.loadingVideosProvider, + mediaLayerRef: this.mediaLayer, + nodesManagerRef: this.nodesManager, + selectionManagerRef: this.selectionManager, + renderEngineRef: this.realTimeDrawEngine, + }); + + this.matteBox = new MatteBox({ + boardCanvasSize: { + width: this.boardCanvasRef.clientWidth, + height: this.boardCanvasRef.clientHeight, + }, + captureCanvasSize: this.realTimeDrawEngine.captureCanvas.getSize(), + uiLayerRef: this.uiLayer, + }); + + // some of the managers has events + // hence, lastly, setup these events + this.setupEventSystem(); + this.setAppMode(this.appMode); + } + + private setAppMode(newAppMode: AppModes) { + this.appMode = newAppMode; + switch (this.appMode) { + case AppModes.PAINT: { + console.log("APPMODE: PAINT"); + this.selectorSquare.disable(); // this breaks the paint if you use it in the wrong spot + this.selectionManager.disable(); // this prevents selection. + this.realTimeDrawEngine.enablePaintMode(); + + uiAccess.toolbarMain.enable(); + uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, { + active: false, + }); + uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.PAINT, { + active: true, + }); + this.matteBox.disable(); + return; + } + case AppModes.SELECT: { + console.log("APPMODE: SELECT"); + this.realTimeDrawEngine.disablePaintMode(); + + this.selectorSquare.enable(); + this.selectionManager.enable(); + uiAccess.toolbarMain.enable(); + uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, { + active: true, + }); + uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.PAINT, { + active: false, + }); + this.matteBox.disable(); + document.body.style.cursor = "default"; + return; + } + case AppModes.PREVIEW: { + console.log("APPMODE: RENDER"); + this.selectorSquare.disable(); + this.selectionManager.disable(); + uiAccess.toolbarMain.disable(); + uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, { + active: false, + }); + this.matteBox.enable(); + document.body.style.cursor = "not-allowed"; + return; + } + case AppModes.RENDERING: { + console.log("APPMODE: RENDER"); + this.selectorSquare.disable(); + this.selectionManager.disable(); + uiAccess.toolbarMain.disable(); + uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, { + active: false, + }); + this.matteBox.enable(true); + document.body.style.cursor = "wait"; + return; + } + case AppModes.INIT: + default: { + console.log("APPMODE: INIT"); + this.selectorSquare.disable(); + uiAccess.toolbarMain.disable(); + this.selectionManager.disable(); + uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, { + active: false, + }); + document.body.style.cursor = "wait"; + this.matteBox.disable(); + } + } + } + private setupEventSystem() { + // Listen to changes in container size + const resizeObserver = new ResizeObserver(() => { + this.onBoardCanvasResize(); + }); + resizeObserver.observe(this.boardCanvasRef); + this.onBoardCanvasResize(); + + // Listen to Nodes + this.selectionManager.eventTarget.addEventListener( + SelectionManagerEvents.NODES_TRANSLATIONS, + ((event: CustomEvent) => { + //console.log("Event: SelectionManager -> Engine", event); + this.commandManager.translateNodes(event.detail); + }) as EventListener, + ); + this.selectionManager.eventTarget.addEventListener( + SelectionManagerEvents.NODES_TRANSFORMATION, + ((event: CustomEvent) => { + //console.log("Event: SelectionManager -> Engine", event); + this.commandManager.transformNodes(event.detail); + }) as EventListener, + ); + + // Listen to Tooolbar Node + uiEvents.toolbarNode.lock.onClick(() => { + this.commandManager.toggleLockNodes(); + }); + uiEvents.toolbarNode.DOWNLOAD.onClick(() => { + const nodes = this.selectionManager.getSelectedNodes(); + if (nodes.size > 1) { + uiAccess.dialogError.show({ + title: "Error: Download Node Content", + message: + "Please do not select more than 1 item for the Download Node Content feature, you can only download 1 item at a time", + }); + return; + } + const node = nodes.values().next().value; + try { + if (node instanceof VideoNode && node.currentUrl) { + downloadURI(node.currentUrl, `Download Video Node-${node.kNode.id}`); + } else { + throw new Error(); + } + } catch { + uiAccess.dialogError.show({ + title: "Error: Download Node Content", + message: "This item does not have content for download.", + }); + } + }); + + uiEvents.toolbarNode.DELETE.onClick(() => + this.commandManager.deleteNodes(), + ); + uiEvents.toolbarNode.MOVE_LAYER_DOWN.onClick(() => + this.commandManager.moveNodesDown(), + ); + uiEvents.toolbarNode.MOVE_LAYER_UP.onClick(() => + this.commandManager.moveNodesUp(), + ); + uiEvents.toolbarNode.REMOVE_BACKGROUND.onClick(() => + this.removeBackground(), + ); + + // Listen to Toolbar Main + uiEvents.toolbarMain.UNDO.onClick(() => { + this.undoStackManager.undo(); + }); + uiEvents.toolbarMain.REDO.onClick(() => { + this.undoStackManager.redo(); + this.realTimeDrawEngine.render(); + }); + + uiEvents.toolbarMain.SAVE.onClick(async (/*event*/) => { + //await this.realTimeDrawEngine.saveOutput(); // This works. + await invoke("open_login_command"); + }); + + uiEvents.toolbarMain.GENERATE.onClick(async (/*event*/) => { + console.log("generate clicked"); + await this.realTimeDrawEngine.generateImage(); + }); + + uiEvents.toolbarMain.SELECT.onClick(() => { + console.log("Toolbar Main >> Select"); + this.setAppMode(AppModes.SELECT); + }); + + uiEvents.toolbarMain.PAINT.onClick(() => { + console.log("Toolbar Main >> Paint"); + this.setAppMode(AppModes.PAINT); + }); + + uiEvents.toolbarMain.ERASER.onClick(() => { + console.log("Toolbar Main >> Eraser"); + }); + + uiEvents.toolbarMain.PREVIEW.onClick(async () => { + console.log("Toolbar Main >> Preview"); + this.setAppMode(AppModes.PREVIEW); + await this.handlePreview(); + this.setAppMode(AppModes.SELECT); + }); + uiEvents.toolbarMain.DOWNLOAD.onClick(async () => { + console.log("Toolbar Main >> Render Download"); + try { + this.setAppMode(AppModes.RENDERING); + } catch (error) { + // throw error to retry + uiAccess.dialogError.show({ + title: "Generation Error", + message: error?.toString() || "Unknown Error", + }); + this.setAppMode(AppModes.SELECT); + } + }); + // Listen to other toolbars + // VideoExtraction Toolbar + + + // Listen to other requests coming from the UI + uiEvents.onGetStagedImage((image) => { + this.addImage(image); + }); + + uiEvents.onAddTextToEngine((textdata) => { + this.addText(textdata); + }); + + let renderTimeout: NodeJS.Timeout; + + uiEvents.promptEvents.onPromptStrengthChanged(async (strength) => { + this.realTimeDrawEngine.currentStrength = strength / 100.0; + clearTimeout(renderTimeout); + renderTimeout = setTimeout(async () => { + await this.realTimeDrawEngine.render(); + }, 1000); + }); + + uiEvents.promptEvents.onPromptTextChanged(async (prompt) => { + this.realTimeDrawEngine.currentPrompt = prompt; + clearTimeout(renderTimeout); + renderTimeout = setTimeout(async () => { + await this.realTimeDrawEngine.render(); + }, 1000); + }); + + uiEvents.onAddShapeToEngine((shapeData) => { + switch (shapeData.shape) { + case "circle": + this.addShape(ShapeType.CIRCLE, 100); + break; + case "square": + this.addShape(ShapeType.SQUARE, 100); + break; + case "triangle": + this.addShape(ShapeType.TRIANGLE, 100); + break; + } + }); + + uiEvents.toolbarMain.onBgColorChanged((data) => { + this.realTimeDrawEngine.updateBackground(data); + }); + + uiEvents.toolbarNode.color.onConfirmChanged((nodeColor) => { + console.log("Color change event triggered with:", nodeColor); + if (!nodeColor) { + console.warn("nodeColor is undefined or null"); + return; + } + if (!nodeColor.kNodeId) { + console.warn("nodeColor.kNodeId is undefined or null"); + return; + } + this.changeNodeColor(nodeColor); + }); + uiEvents.toolbarMain.onPaintColorChanged((color) => { + this.realTimeDrawEngine.paintColor = color; + }); + + uiEvents.toolbarMain.onEraseBrushSizeChanged((size: number) => { + this.realTimeDrawEngine.paintBrushSize = size; + }); + } + + removeBackground() { + const selectedNodeSet = this.selectionManager.getSelectedNodes(); + + if (selectedNodeSet.size !== 1) { + return; // Can only remove background from a single image at a time. + } + + const [selectedNode] = selectedNodeSet; + + const removeBgInvocation = async ( + selectedNode: ImageNode, + callback: ( + existingImageNode: ImageNode, + createdImage: ImageBitmap, + ) => void, + ) => { + const image: HTMLImageElement = selectedNode.kNode.image(); + const base64Bitmap = await EncodeImageBitmapToBase64(image); + const base64BitmapResponse = await invoke("remove_background", { + image: base64Bitmap, + }); + + const createdImage = await DecodeBase64ToImage( + base64BitmapResponse as string, + ); + + callback(selectedNode, createdImage); + }; + + const _promise = removeBgInvocation( + selectedNode, + (selectedNode: ImageNode, createdImage: ImageBitmap) => { + if (!this.nodesManager.hasNode(selectedNode)) { + return; + } + this.commandManager.deleteSpecificNodes([selectedNode]); + this.addImageFromImageBitmap(createdImage); + }, + ); + } + + disableAllButtons() { + const buttonNames = Object.values(ToolbarNodeButtonNames); + for (const name of buttonNames) { + uiAccess.toolbarNode.changeButtonState(name, { disabled: true }); + } + } + + async enableAllButtons() { + const buttonNames = Object.values(ToolbarNodeButtonNames); + for (const name of buttonNames) { + await uiAccess.toolbarNode.changeButtonState(name, { disabled: false }); + } + } + + sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private onBoardCanvasResize() { + console.log("onBoardCanvasResize Event called"); + // Print window dimensions for debugging + console.log("Window dimensions:", { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + outerWidth: window.outerWidth, + outerHeight: window.outerHeight, + }); + + this.realTimeDrawEngine.updateCaptureCanvas( + window.innerWidth, + window.innerHeight, + ); + + this.matteBox.updateSize({ + boardCanvasSize: { + width: this.boardCanvasRef.offsetWidth, + height: this.boardCanvasRef.offsetHeight, + }, + }); + this.uiLayer.draw(); + this.stage.width(this.boardCanvasRef.offsetWidth); + this.stage.height(this.boardCanvasRef.offsetHeight); + this.stage.draw(); // Redraw the canvas + } + + // Sandbox is quickly a way to test your idea. + public async sandbox() {} + + public onMessage(event: MessageEvent) { + console.log("Message From Shared Worker"); + console.log(event); + } + + public initializeStage(sceneToken?: string) { + // load canvas that was originaly saved TODO Save manager for resharing. + uiAccess.toolbarNode.hide(); + uiAccess.loadingBar.hide(); + // load the scene if there's a scenetoken + if (sceneToken) { + this.sceneManager.loadScene(sceneToken); + } + this.setupStage(); + // this.populateWithDebugItems(); + } + public isInitialized() { + return this.stage !== null; + } + + public async setupStage() { + // Frame rate inicator + // const textNode = new Konva.Text({ + // x: 10, + // y: 80, + // text: "", + // fontSize: 18, + // fontFamily: "Source Sans 3", + // fill: "white", + // }); + // const anim = new Konva.Animation((frame) => { + // if (frame && import.meta.env.DEV) { + // const timeDiff = frame.timeDiff; + // const frameRate = frame.frameRate; + // textNode.setText( + // `FrameTime: ${timeDiff.toFixed(0)} ms\nFrameRate: ${frameRate.toFixed(0)} fps`, + // ); + // } + // }, this.mediaLayer); + + // anim.start(); + //this.uiLayer.add(textNode); + this.addKeyboardShortcuts(); + } + + private changeNodeColor(nodeColor: NodeColor) { + console.log("Changing node color:", nodeColor); + // Find the node that is selected with the id + const idSelector = "#" + nodeColor.kNodeId; + const selectedNode = this.mediaLayer.findOne(idSelector); + console.log("Selected node:", selectedNode); + console.log("ID selector:", idSelector); + console.log("Media layer:", this.mediaLayer); + // TODO: Change the color of the node + // Turns out shapes are image nodes... how do we change the image's fill?? + if (selectedNode) { + // Get the shape node from the real time drawing engine's image nodes + const shapeNode = this.realTimeDrawEngine.findImageNodeById( + nodeColor.kNodeId, + ); + if (shapeNode && shapeNode instanceof ShapeNode) { + // Create new shape node with updated color + const newShapeNode = new ShapeNode({ + canvasPosition: this.realTimeDrawEngine.captureCanvas.position(), + canvasSize: this.realTimeDrawEngine.captureCanvas.size(), + shapeType: shapeNode.shapeType, + size: shapeNode.kNode.size(), + color: nodeColor.color, + mediaLayerRef: this.mediaLayer, + selectionManagerRef: this.selectionManager, + loaded: async () => { + this.realTimeDrawEngine.render(); + }, + }); + newShapeNode.kNode.position(shapeNode.kNode.position()); + newShapeNode.kNode.zIndex(shapeNode.kNode.zIndex()); + + // Remove old node and add new one + shapeNode.kNode.destroy(); + this.commandManager.createNode(newShapeNode); + } + } + } + + public addText(textNodeData: TextNodeData) { + const textNode = new TextNode({ + textNodeData: textNodeData, + mediaLayerRef: this.mediaLayer, + selectionManagerRef: this.selectionManager, + canvasPosition: this.realTimeDrawEngine.captureCanvas.position(), + canvasSize: this.realTimeDrawEngine.captureCanvas.size(), + }); + this.commandManager.createNode(textNode); + } + + public addShape(type: ShapeType, size: number, color?: string) { + const shapeNode = new ShapeNode({ + canvasPosition: this.realTimeDrawEngine.captureCanvas.position(), + canvasSize: this.realTimeDrawEngine.captureCanvas.size(), + shapeType: type, + size: { width: size, height: size }, + color: color, + mediaLayerRef: this.mediaLayer, + selectionManagerRef: this.selectionManager, + loaded: async () => { + await this.realTimeDrawEngine.render(); + this.setAppMode(AppModes.SELECT); + }, + }); + + this.commandManager.createNode(shapeNode); + } + public addPreviewCopy(image: Image.Konva) { + // Start of Selection + const copyNode = new PreviewCopyNode({ + image: image, + mediaLayerRef: this.mediaLayer, + selectionManagerRef: this.selectionManager, + loaded: async () => { + this.realTimeDrawEngine.render(); + this.setAppMode(AppModes.SELECT); + }, + }); + this.commandManager.createNode(copyNode); + } + public addImage(imageFile: File) { + const imageNode = new ImageNode({ + mediaLayerRef: this.mediaLayer, + canvasPosition: this.realTimeDrawEngine.captureCanvas.position(), + canvasSize: this.realTimeDrawEngine.captureCanvas.size(), + imageFile: imageFile, + selectionManagerRef: this.selectionManager, + loaded: async () => { + await this.realTimeDrawEngine.render(); + this.setAppMode(AppModes.SELECT); + }, + }); + + this.commandManager.createNode(imageNode); + } + + public addImageFromImageBitmap(imageBitmap: ImageBitmap) { + const imageNode = new ImageNode({ + mediaLayerRef: this.mediaLayer, + canvasPosition: this.realTimeDrawEngine.captureCanvas.position(), + canvasSize: this.realTimeDrawEngine.captureCanvas.size(), + imageBitmap: imageBitmap, + selectionManagerRef: this.selectionManager, + loaded: async () => { + await this.realTimeDrawEngine.render(); + this.setAppMode(AppModes.SELECT); + }, + }); + + this.commandManager.createNode(imageNode); + } + + public addPaintNode( + canvas: HTMLCanvasElement, + lineBounds: { + width: number; + height: number; + x: number; + y: number; + }, + ) { + const node = new PaintNode({ + canvasElement: canvas, + lineBounds: lineBounds, + mediaLayerRef: this.mediaLayer, + selectionManagerRef: this.selectionManager, + loaded: async () => { + await this.realTimeDrawEngine.render(); + }, + }); + this.commandManager.createNode(node); + } + + // Events for Undo and Redo + private addKeyboardShortcuts() { + window.addEventListener("keydown", (event) => { + if (event.ctrlKey && event.key === "z") { + this.undoStackManager.undo(); + } else if ( + (event.ctrlKey && event.key === "y") || + (event.ctrlKey && event.shiftKey && event.key === "Z") + ) { + this.undoStackManager.redo(); + } else if (event.key === "Delete") { + this.commandManager.deleteNodes(); + } + }); + } +} + +// TODO: Move to utilities? +function downloadURI(uri: string, name: string) { + const link = document.createElement("a"); + link.download = name; + link.target = "_blank"; + link.href = uri; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EditEngine.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EditEngine.ts new file mode 100644 index 0000000000..4f1d5f980b --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EditEngine.ts @@ -0,0 +1,371 @@ +import Konva from "konva"; + +import { uiAccess } from "../signals"; +import { CommandManager, MatteBox } from "./EngineUtitlities"; +import { ImageNode } from "./Nodes"; +import { PaintNode } from "./Nodes/PaintNode"; +import { + NodesManager, + NodesTranslationEventDetails, + NodeTransformationEventDetails, + NodeTransformer, + SelectionManager, + SelectionManagerEvents, + SelectorSquare, +} from "./NodesManagers"; +import { UndoStackManager } from "./UndoRedo"; + +import { ToolbarMainButtonNames } from "../components/features/ToolbarMain/enum"; +import { EditModes, VideoResolutions } from "./constants"; + +import { ToolbarNodeButtonNames } from "../components/features/ToolbarNode/enums"; + + +export interface RenderingOptions { + artstyle: string; + positivePrompt: string; + negativePrompt: string; + cinematic: boolean; + enginePreProcessing: boolean; + faceDetail: boolean; + lipSync: boolean; + upscale: boolean; + styleStrength: number; +} + +export class EditEngine { + private editMode: EditModes = EditModes.SELECT; + private boardCanvasRef: HTMLDivElement; + private stage: Konva.Stage; + + private mediaLayer: Konva.Layer; + private nodeIsolationLayer: Konva.Layer; + private uiLayer: Konva.Layer; + private offScreenCanvas: OffscreenCanvas; + private realTimeDrawEngine: RealTimeDrawEngine; + private nodesManager: NodesManager; + private nodeTransformer: NodeTransformer; + private selectionManager: SelectionManager; + private selectorSquare: SelectorSquare; + private matteBox: MatteBox; + + private undoStackManager: UndoStackManager; + private commandManager: CommandManager; + + public segmentationButtonCanBePressed: boolean = true; + // signal reference + constructor(boardCanvasRef: HTMLDivElement) { + if (import.meta.env.DEV) { + console.log("Engine Created"); + } + this.editMode = EditModes.SELECT; + + this.boardCanvasRef = boardCanvasRef; + + this.stage = new Konva.Stage({ + container: this.boardCanvasRef, + width: window.innerWidth, + height: window.innerHeight, + }); + //this.bgLayer = new Konva.Layer(); + + this.mediaLayer = new Konva.Layer(); + + this.nodeIsolationLayer = new Konva.Layer(); + this.uiLayer = new Konva.Layer(); + + //this.stage.add(this.bgLayer); + this.stage.add(this.mediaLayer); + this.stage.add(this.nodeIsolationLayer); + this.stage.add(this.uiLayer); + + // Konva Transformer + this.nodeTransformer = new NodeTransformer(); + this.uiLayer.add(this.nodeTransformer.getKonvaNode()); + + // core layer for all the work done. + this.offScreenCanvas = new OffscreenCanvas(0, 0); + + // Collection of all Nodes + this.nodesManager = new NodesManager(); + // Partial Collection of selected Nodes + this.selectionManager = new SelectionManager({ + nodeTransformerRef: this.nodeTransformer, + mediaLayerRef: this.mediaLayer, + }); + + this.realTimeDrawEngine = new RealTimeDrawEngine({ + width: VideoResolutions.SQUARE_1024.width, + height: VideoResolutions.SQUARE_1024.height, + mediaLayerRef: this.mediaLayer, + offScreenCanvas: this.offScreenCanvas, + onDraw: async (canvas, lineBounds) => { + await this.addPaintNode(canvas, lineBounds); + }, + }); + + // Selector Square to select Nodes + this.selectorSquare = new SelectorSquare({ + captureCanvasRef: this.realTimeDrawEngine.captureCanvas, + mediaLayerRef: this.mediaLayer, + nodesManagerRef: this.nodesManager, + selectionManagerRef: this.selectionManager, + stageRef: this.stage, + }); + this.uiLayer.add(this.selectorSquare.getKonvaNode()); + + //Collection of commands for undo-redo + this.undoStackManager = new UndoStackManager(() => { + this.realTimeDrawEngine.render(); + }); + this.commandManager = new CommandManager({ + mediaLayerRef: this.mediaLayer, + nodesManagerRef: this.nodesManager, + nodeTransformerRef: this.nodeTransformer, + selectionManagerRef: this.selectionManager, + renderEngineRef: this.realTimeDrawEngine, + undoStackManagerRef: this.undoStackManager, + }); + + this.matteBox = new MatteBox({ + boardCanvasSize: { + width: this.boardCanvasRef.clientWidth, + height: this.boardCanvasRef.clientHeight, + }, + captureCanvasSize: this.realTimeDrawEngine.captureCanvas.getSize(), + uiLayerRef: this.uiLayer, + }); + + // some of the managers has events + // hence, lastly, setup these events + this.setupEventSystem(); + this.setEditMode(this.editMode); + } + + private setEditMode(newEditMode: EditModes) { + this.editMode = newEditMode; + switch (this.editMode) { + case EditModes.SELECT: + default: { + console.log("EDITMODE: SELECT"); + this.realTimeDrawEngine.disablePaintMode(); + + this.selectorSquare.enable(); + this.selectionManager.enable(); + uiAccess.toolbarMain.enable(); + uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, { + active: true, + }); + uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.PAINT, { + active: false, + }); + this.matteBox.disable(); + document.body.style.cursor = "default"; + return; + } + case EditModes.EDIT: { + console.log("EDITMODE: EDIT"); + this.realTimeDrawEngine.enablePaintMode(); + this.selectorSquare.disable(); + this.selectionManager.disable(); + } + } + } + private setupEventSystem() { + // Listen to changes in container size + const resizeObserver = new ResizeObserver(() => { + this.onBoardCanvasResize(); + }); + resizeObserver.observe(this.boardCanvasRef); + this.onBoardCanvasResize(); + + // Listen to Nodes + this.selectionManager.eventTarget.addEventListener( + SelectionManagerEvents.NODES_TRANSLATIONS, + ((event: CustomEvent) => { + //console.log("Event: SelectionManager -> Engine", event); + this.commandManager.translateNodes(event.detail); + }) as EventListener, + ); + this.selectionManager.eventTarget.addEventListener( + SelectionManagerEvents.NODES_TRANSFORMATION, + ((event: CustomEvent) => { + //console.log("Event: SelectionManager -> Engine", event); + this.commandManager.transformNodes(event.detail); + }) as EventListener, + ); + + // Listen to Toolbar Main + // uiEvents.toolbarMain.UNDO.onClick(() => { + // this.undoStackManager.undo(); + // }); + // uiEvents.toolbarMain.REDO.onClick(() => { + // this.undoStackManager.redo(); + // this.realTimeDrawEngine.render(); + // }); + + // uiEvents.toolbarMain.SAVE.onClick(async (/*event*/) => { + // await this.realTimeDrawEngine.saveOutput(); + // }); + + // uiEvents.toolbarMain.SELECT.onClick(() => { + // console.log("Toolbar Main >> Select"); + // this.setAppMode(AppModes.SELECT); + // }); + + // uiEvents.toolbarMain.PAINT.onClick(() => { + // console.log("Toolbar Main >> Paint"); + // this.setAppMode(AppModes.PAINT); + // }); + } + + disableAllButtons() { + const buttonNames = Object.values(ToolbarNodeButtonNames); + for (const name of buttonNames) { + uiAccess.toolbarNode.changeButtonState(name, { disabled: true }); + } + } + + async enableAllButtons() { + const buttonNames = Object.values(ToolbarNodeButtonNames); + for (const name of buttonNames) { + await uiAccess.toolbarNode.changeButtonState(name, { disabled: false }); + } + } + + sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private onBoardCanvasResize() { + this.realTimeDrawEngine.updateCaptureCanvas(0, 0); + console.log("onBoardCanvasResize Event called"); + console.log("Board Canvas values below"); + console.log(this.boardCanvasRef.offsetWidth); + console.log(this.boardCanvasRef.offsetHeight); + + this.matteBox.updateSize({ + boardCanvasSize: { + width: this.boardCanvasRef.offsetWidth, + height: this.boardCanvasRef.offsetHeight, + }, + }); + + this.uiLayer.draw(); + this.stage.width(this.boardCanvasRef.offsetWidth); + this.stage.height(this.boardCanvasRef.offsetHeight); + this.stage.draw(); // Redraw the canvas + } + + // Sandbox is quickly a way to test your idea. + public async sandbox() {} + + public onMessage(event: MessageEvent) { + console.log("Message From Shared Worker"); + console.log(event); + } + + public initializeStage() { + // load canvas that was originaly saved TODO Save manager for resharing. + uiAccess.toolbarNode.hide(); + uiAccess.loadingBar.hide(); + this.setupStage(); + } + + public isInitialized() { + return this.stage !== null; + } + + public async setupStage() { + // Frame rate inicator + // const textNode = new Konva.Text({ + // x: 10, + // y: 80, + // text: "", + // fontSize: 18, + // fontFamily: "Source Sans 3", + // fill: "white", + // }); + // const anim = new Konva.Animation((frame) => { + // if (frame && import.meta.env.DEV) { + // const timeDiff = frame.timeDiff; + // const frameRate = frame.frameRate; + // textNode.setText( + // `FrameTime: ${timeDiff.toFixed(0)} ms\nFrameRate: ${frameRate.toFixed(0)} fps`, + // ); + // } + // }, this.mediaLayer); + + // anim.start(); + // this.uiLayer.add(textNode); + this.addKeyboardShortcuts(); + } + + public addImage(imageFile: File) { + const imageNode = new ImageNode({ + mediaLayerRef: this.mediaLayer, + canvasPosition: this.realTimeDrawEngine.captureCanvas.position(), + canvasSize: this.realTimeDrawEngine.captureCanvas.size(), + imageFile: imageFile, + selectionManagerRef: this.selectionManager, + loaded: async () => { + await this.realTimeDrawEngine.render(); + this.setEditMode(EditModes.SELECT); + }, + }); + + this.commandManager.createNode(imageNode); + } + + public addImageFromImageBitmap(imageBitmap: ImageBitmap) { + const imageNode = new ImageNode({ + mediaLayerRef: this.mediaLayer, + canvasPosition: this.realTimeDrawEngine.captureCanvas.position(), + canvasSize: this.realTimeDrawEngine.captureCanvas.size(), + imageBitmap: imageBitmap, + selectionManagerRef: this.selectionManager, + loaded: async () => { + await this.realTimeDrawEngine.render(); + this.setEditMode(EditModes.SELECT); + }, + }); + + this.commandManager.createNode(imageNode); + } + + public addPaintNode( + canvas: HTMLCanvasElement, + lineBounds: { + width: number; + height: number; + x: number; + y: number; + }, + ) { + var node = new PaintNode({ + canvasElement: canvas, + lineBounds: lineBounds, + mediaLayerRef: this.mediaLayer, + selectionManagerRef: this.selectionManager, + loaded: async () => { + await this.realTimeDrawEngine.render(); + }, + }); + this.commandManager.createNode(node); + } + // Events for Undo and Redo + private addKeyboardShortcuts() { + window.addEventListener("keydown", (event) => { + if (event.ctrlKey && event.key === "z") { + this.undoStackManager.undo(); + } else if ( + (event.ctrlKey && event.key === "y") || + (event.ctrlKey && event.shiftKey && event.key === "Z") + ) { + this.undoStackManager.redo(); + } else if (event.key === "Delete") { + this.commandManager.deleteNodes(); + } + }); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/Base64Helpers.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/Base64Helpers.ts new file mode 100644 index 0000000000..8919e7d73f --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/Base64Helpers.ts @@ -0,0 +1,7 @@ +const ensureBase64Prefix = (base64String: string) => { + return base64String.startsWith("data:") + ? base64String + : `data:image/png;base64,${base64String}`; +} + +export { ensureBase64Prefix }; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/CommandManager.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/CommandManager.ts new file mode 100644 index 0000000000..5463d4e9e4 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/CommandManager.ts @@ -0,0 +1,173 @@ +import Konva from "konva"; +import { ImageNode, ShapeNode, TextNode, VideoNode } from "../Nodes"; +import { MediaNode, Position, RGBColor, Transformation } from "../types"; +import { + AddChromaKey, + CreateCommand, + DeleteCommand, + LockNodesCommand, + MoveLayerDown, + MoveLayerUp, + RemoveChromaKey, + TransformCommand, + TranslateCommand, + UndoStackManager, + UnlockNodesCommand, + UseVideoExtraction, +} from "../UndoRedo"; +import { + NodesManager, + NodeTransformer, + SelectionManager, +} from "../NodesManagers"; +import { RealTimeDrawEngine } from "../RenderingPrimitives/RealTimeDrawEngine"; +import { PaintNode } from "../Nodes/PaintNode"; + +interface EngineReferences { + mediaLayerRef: Konva.Layer; + nodesManagerRef: NodesManager; + nodeTransformerRef: NodeTransformer; + selectionManagerRef: SelectionManager; + renderEngineRef: RealTimeDrawEngine; + undoStackManagerRef: UndoStackManager; +} + +export class CommandManager { + private mediaLayerRef: Konva.Layer; + private nodesManagerRef: NodesManager; + private nodeTransformerRef: NodeTransformer; + private selectionManagerRef: SelectionManager; + private renderEngineRef: RealTimeDrawEngine; + private undoStackManagerRef: UndoStackManager; + + constructor(engineRefs: EngineReferences) { + this.mediaLayerRef = engineRefs.mediaLayerRef; + this.nodesManagerRef = engineRefs.nodesManagerRef; + this.nodeTransformerRef = engineRefs.nodeTransformerRef; + this.selectionManagerRef = engineRefs.selectionManagerRef; + this.renderEngineRef = engineRefs.renderEngineRef; + this.undoStackManagerRef = engineRefs.undoStackManagerRef; + } + + createNode(node: MediaNode) { + const command = new CreateCommand({ + nodes: new Set([node]), + mediaLayerRef: this.mediaLayerRef, + nodesManagerRef: this.nodesManagerRef, + nodeTransformerRef: this.nodeTransformerRef, + selectionManagerRef: this.selectionManagerRef, + renderEngineRef: this.renderEngineRef, + }); + this.undoStackManagerRef.executeCommand(command); + + // Set the kNode's manual id to same as kNode's internal _id + // This helps identify the kNode in the Konva layer with find + node.kNode.id(node.kNode._id.toString()); + this.renderEngineRef.render(); + } + deleteNodes() { + const nodes = this.selectionManagerRef.getSelectedNodes(); + const command = new DeleteCommand({ + nodes: nodes, + mediaLayerRef: this.mediaLayerRef, + nodesManagerRef: this.nodesManagerRef, + nodeTransformerRef: this.nodeTransformerRef, + selectionManagerRef: this.selectionManagerRef, + renderEngineRef: this.renderEngineRef, + }); + this.undoStackManagerRef.executeCommand(command); + this.renderEngineRef.render(); + } + + deleteSpecificNodes(nodes: MediaNode[]) { + const command = new DeleteCommand({ + nodes: nodes, + mediaLayerRef: this.mediaLayerRef, + nodesManagerRef: this.nodesManagerRef, + nodeTransformerRef: this.nodeTransformerRef, + selectionManagerRef: this.selectionManagerRef, + renderEngineRef: this.renderEngineRef, + }); + this.undoStackManagerRef.executeCommand(command); + this.renderEngineRef.render(); + } + + toggleLockNodes() { + const nodes = this.selectionManagerRef.getSelectedNodes(); + const node = nodes.values().next().value; + if (!node) { + console.log("Node Not Found for Locking"); + return; + } + if (node.isLocked()) { + const command = new UnlockNodesCommand({ + nodes: this.selectionManagerRef.getSelectedNodes(), + }); + this.undoStackManagerRef.executeCommand(command); + } else { + const command = new LockNodesCommand({ + nodes: this.selectionManagerRef.getSelectedNodes(), + }); + this.undoStackManagerRef.executeCommand(command); + } + } + moveNodesUp() { + const command = new MoveLayerUp({ + nodes: this.selectionManagerRef.getSelectedNodes(), + nodesManagerRef: this.nodesManagerRef, + mediaLayerRef: this.mediaLayerRef, + }); + this.undoStackManagerRef.executeCommand(command); + this.renderEngineRef.render(); + } + moveNodesDown() { + const command = new MoveLayerDown({ + nodes: this.selectionManagerRef.getSelectedNodes(), + nodesManagerRef: this.nodesManagerRef, + mediaLayerRef: this.mediaLayerRef, + }); + this.undoStackManagerRef.executeCommand(command); + this.renderEngineRef.render(); + } + translateNodes(props: { + nodes: Set; + initialPositions: Map; + finalPositions: Map; + }) { + const command = new TranslateCommand({ + ...props, + layerRef: this.mediaLayerRef, + }); + this.undoStackManagerRef.pushCommand(command); + this.renderEngineRef.render(); + } + transformNodes(props: { + nodes: Set; + initialTransformations: Map; + finalTransformations: Map; + }) { + const command = new TransformCommand({ + ...props, + layerRef: this.mediaLayerRef, + }); + this.undoStackManagerRef.pushCommand(command); + this.renderEngineRef.render(); + } + addChromaKey(props: { videoNode: VideoNode; newChromaColor: RGBColor }) { + const command = new AddChromaKey(props); + this.undoStackManagerRef.executeCommand(command); + } + removeChromaKey(props: { videoNode: VideoNode }) { + const command = new RemoveChromaKey(props); + this.undoStackManagerRef.executeCommand(command); + } + useVideoExtraction(props: { + videoNode: VideoNode; + extractionUrl: string; + prevIsChroma: boolean; + prevChromaColor: RGBColor; + }) { + const command = new UseVideoExtraction(props); + this.undoStackManagerRef.pushCommand(command); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/MatteBox.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/MatteBox.ts new file mode 100644 index 0000000000..f828a425b1 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/MatteBox.ts @@ -0,0 +1,134 @@ +import Konva from "konva"; +import { Size } from "../types"; +import { Colors } from "../constants"; +import { uiAccess } from "~/signals"; + +export class MatteBox { + private matteBoxPoly: Konva.Line; + private lightBoxRect: Konva.Rect; + private uiLayerRef: Konva.Layer; + private boardCanvasSize: Size; + private captureCanvasSize: Size; + + constructor({ + boardCanvasSize, + captureCanvasSize, + uiLayerRef, + }: { + boardCanvasSize: Size; + captureCanvasSize: Size; + uiLayerRef: Konva.Layer; + }) { + this.uiLayerRef = uiLayerRef; + this.boardCanvasSize = boardCanvasSize; + this.captureCanvasSize = captureCanvasSize; + this.matteBoxPoly = new Konva.Line({ + points: getMatteBoxPolygonPoints({ + boardCanvasSize: this.boardCanvasSize, + captureCanvasSize: this.captureCanvasSize, + }), + fill: Colors.translucentBlack, + closed: true, + }); + this.lightBoxRect = new Konva.Rect({ + size: captureCanvasSize, + position: { + x: (boardCanvasSize.width - captureCanvasSize.width) / 2, + y: (boardCanvasSize.height - captureCanvasSize.height) / 2, + }, + fill: Colors.transparent, + // fill: "rgba(100,0,0,0.3)", + }); + } + public enable(showMagicBox?: boolean) { + this.uiLayerRef.add(this.matteBoxPoly); + this.uiLayerRef.add(this.lightBoxRect); + if (showMagicBox) { + uiAccess.magicBox.show(); + } + } + public disable() { + this.matteBoxPoly.remove(); + this.lightBoxRect.remove(); + if (uiAccess.magicBox.isShowing()) { + uiAccess.magicBox.hide(); + } + } + public updateSize({ + boardCanvasSize, + captureCanvasSize, + }: { + boardCanvasSize?: Size; + captureCanvasSize?: Size; + }) { + if (boardCanvasSize) { + this.boardCanvasSize = boardCanvasSize; + } + if (captureCanvasSize) { + this.captureCanvasSize = captureCanvasSize; + } + this.matteBoxPoly.points( + getMatteBoxPolygonPoints({ + boardCanvasSize: this.boardCanvasSize, + captureCanvasSize: this.captureCanvasSize, + }), + ); + this.lightBoxRect.setAttrs({ + size: this.captureCanvasSize, + position: { + x: (this.boardCanvasSize.width - this.captureCanvasSize.width) / 2, + y: (this.boardCanvasSize.height - this.captureCanvasSize.height) / 2, + }, + }); + } +} + +function getMatteBoxPolygonPoints({ + boardCanvasSize: board, + captureCanvasSize: capture, +}: { + boardCanvasSize: Size; + captureCanvasSize: Size; +}) { + return [ + //top left + 0, + 0, + // top right + board.width, + 0, + //bottom right + board.width, + board.height, + //bottom middle + board.width / 2, + board.height, + //canvas bottom middle, + board.width / 2, + (board.height - capture.height) / 2 + capture.height, + //canvas bottom right, + (board.width - capture.width) / 2 + capture.width, + (board.height - capture.height) / 2 + capture.height, + //canvas top right, + (board.width - capture.width) / 2 + capture.width, + (board.height - capture.height) / 2, + //canvas top left, + (board.width - capture.width) / 2, + (board.height - capture.height) / 2, + //canvas bottom left, + (board.width - capture.width) / 2, + (board.height - capture.height) / 2 + capture.height, + //canvas bottom middle, + board.width / 2, + (board.height - capture.height) / 2 + capture.height, + //bottom middle + board.width / 2, + board.height, + //bottom left + 0, + board.height, + //back to top left + 0, + 0, + ]; +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/SceneManager.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/SceneManager.ts new file mode 100644 index 0000000000..2d6360adfc --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/SceneManager.ts @@ -0,0 +1,229 @@ +import Konva from "konva"; +import { v4 as uuidv4 } from "uuid"; +import { MediaFilesApi, MediaUploadApi } from "~/Classes/ApiManager"; +import { NodesManager, SelectionManager } from "../NodesManagers"; +import { + ImageNodeData, + NodeData, + TextNodeData, + TransformationData, + VideoNodeData, +} from "../types"; +import { uiAccess } from "~/signals"; +import { NavigateFunction } from "react-router-dom"; +import { NodeType } from "../Nodes/constants"; +import { ImageNode, VideoNode, TextNode } from "../Nodes"; +import { RenderEngine } from "../RenderingPrimitives/RenderEngine"; +import { LoadingVideosProvider } from "./LoadingVideosProvider"; + +export class SceneManager { + private navigateRef: NavigateFunction; + + private loadingVideosProviderRef: LoadingVideosProvider; + private mediaLayerRef: Konva.Layer; + private nodesManagerRef: NodesManager; + private selectionManagerRef: SelectionManager; + private renderEngineRef: RenderEngine; + private currentSceneToken: string | undefined; + + constructor({ + navigateRef, + loadingVideosProviderRef, + mediaLayerRef, + nodesManagerRef, + selectionManagerRef, + renderEngineRef, + }: { + navigateRef: NavigateFunction; + loadingVideosProviderRef: LoadingVideosProvider; + mediaLayerRef: Konva.Layer; + nodesManagerRef: NodesManager; + selectionManagerRef: SelectionManager; + renderEngineRef: RenderEngine; + }) { + this.navigateRef = navigateRef; + this.loadingVideosProviderRef = loadingVideosProviderRef; + this.mediaLayerRef = mediaLayerRef; + this.nodesManagerRef = nodesManagerRef; + this.selectionManagerRef = selectionManagerRef; + this.renderEngineRef = renderEngineRef; + } + + public async saveScene() { + const mediaUploadApi = new MediaUploadApi(); + const timestamp = new Date(); + const sceneTitle = + "board_" + timestamp.toISOString().substring(0, 19).replace(/\D/g, ""); + + const saveJson = this.extractAllNodesData(); + console.log("saveJson", saveJson); + const file = new File([saveJson], `${sceneTitle}`, { + type: "application/json", + }); + if (!this.currentSceneToken) { + const uploadResponse = await mediaUploadApi.UploadNewScene({ + blob: file, + fileName: file.name, + uuid: uuidv4(), + }); + console.log(uploadResponse); + if (!uploadResponse.success || !uploadResponse.data) { + uiAccess.dialogError.show({ + title: "Save New Scene Error", + message: uploadResponse.errorMessage, + }); + return; + } + this.currentSceneToken = uploadResponse.data; + this.navigateRef(`/${this.currentSceneToken}`); + } else { + const uploadResponse = await mediaUploadApi.UploadSavedScene({ + blob: file, + fileName: file.name, + uuid: uuidv4(), + mediaToken: this.currentSceneToken, + }); + console.log(uploadResponse); + if (!uploadResponse.success || !uploadResponse.data) { + uiAccess.dialogError.show({ + title: "Save Scene Error", + message: uploadResponse.errorMessage, + }); + return; + } + } + } + + public async loadScene(mediaFileToken: string) { + const mediaFileApi = new MediaFilesApi(); + const getSceneResponse = await mediaFileApi.GetMediaFileByToken({ + mediaFileToken: mediaFileToken, + }); + if (!getSceneResponse.success || !getSceneResponse.data) { + uiAccess.dialogError.show({ + title: "Load Scene Error", + message: getSceneResponse.errorMessage, + }); + return; + } + this.currentSceneToken = mediaFileToken; + // console.log(getSceneResponse.data); + // console.log(getSceneResponse.data.public_bucket_path); + const fileResponse = await fetch(getSceneResponse.data.public_bucket_url); + if (!fileResponse.ok) { + uiAccess.dialogError.show({ + title: "Load Scene Error", + message: "Failed to download Scene File", + }); + return; + } + const fileBlob = await fileResponse.blob(); + + const loadJson: NodeData[] = await new Promise((resolve, reject) => { + const reader = new FileReader(); + const readerError = () => { + uiAccess.dialogError.show({ + title: "Load Scene Error", + message: "Failed to read Scene File", + }); + this.navigateRef("/"); + console.log("debug file Response", fileResponse); + console.log("debug file Blob", fileBlob); + reject(); + }; + reader.onloadend = () => { + try { + resolve(JSON.parse(reader.result as string)); + } catch { + readerError(); + } + }; + reader.onerror = () => { + readerError(); + }; + reader.readAsText(fileBlob); + }); + + this.rebuildScene(loadJson); + } + + private extractAllNodesData() { + const nodesData: NodeData[] = []; + this.nodesManagerRef.getAllNodes().forEach((node) => { + const nodeData = node.getNodeData( + this.renderEngineRef.captureCanvas.position(), + ); + if (nodeData !== null) { + nodesData.push(nodeData); + } + }); + nodesData.sort((a, b) => a.transform.zIndex - b.transform.zIndex); + return JSON.stringify(nodesData); + } + + private rebuildScene(nodesData: NodeData[]) { + // console.log(nodesData); + nodesData.forEach((nodeDatum) => { + if (nodeDatum.type === NodeType.IMAGE && nodeDatum.imageNodeData) { + this.addImage(nodeDatum.imageNodeData, nodeDatum.transform); + } else if (nodeDatum.type === NodeType.VIDEO && nodeDatum.videoNodeData) { + this.addVideo(nodeDatum.videoNodeData, nodeDatum.transform); + } else if (nodeDatum.type === NodeType.TEXT && nodeDatum.textNodeData) { + this.addText({ + textNodeData: nodeDatum.textNodeData, + transform: nodeDatum.transform, + }); + } + }); + } + + private addImage( + imageNodeData: ImageNodeData, + transform: TransformationData, + ) { + const imageNode = new ImageNode({ + mediaFileUrl: imageNodeData.mediaFileUrl, + mediaLayerRef: this.mediaLayerRef, + canvasPosition: this.renderEngineRef.captureCanvas.position(), + canvasSize: this.renderEngineRef.captureCanvas.size(), + transform: transform, + selectionManagerRef: this.selectionManagerRef, + }); + this.nodesManagerRef.saveNode(imageNode); + this.renderEngineRef.addNodes(imageNode); + } + private addVideo( + videoNodeData: VideoNodeData, + transform: TransformationData, + ) { + const videoNode = new VideoNode({ + loadingVideosProviderRef: this.loadingVideosProviderRef, + mediaLayerRef: this.mediaLayerRef, + selectionManagerRef: this.selectionManagerRef, + canvasPosition: this.renderEngineRef.captureCanvas.position(), + canvasSize: this.renderEngineRef.captureCanvas.size(), + videoNodeData: videoNodeData, + transform: transform, + }); + this.nodesManagerRef.saveNode(videoNode); + this.renderEngineRef.addNodes(videoNode); + } + private addText({ + textNodeData, + transform, + }: { + textNodeData: TextNodeData; + transform: TransformationData; + }) { + const textNode = new TextNode({ + textNodeData: textNodeData, + transform: transform, + mediaLayerRef: this.mediaLayerRef, + canvasPosition: this.renderEngineRef.captureCanvas.position(), + canvasSize: this.renderEngineRef.captureCanvas.size(), + selectionManagerRef: this.selectionManagerRef, + }); + this.nodesManagerRef.saveNode(textNode); + this.renderEngineRef.addNodes(textNode); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/index.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/index.ts new file mode 100644 index 0000000000..999f7927e0 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/EngineUtitlities/index.ts @@ -0,0 +1,3 @@ +export { CommandManager } from "./CommandManager"; +export { MatteBox } from "./MatteBox"; +export { SceneManager } from "./SceneManager"; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/GenerationEngine.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/GenerationEngine.ts new file mode 100644 index 0000000000..0c038b277a --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/GenerationEngine.ts @@ -0,0 +1,46 @@ +import { invoke } from "@tauri-apps/api/core"; +import { + cancelGeneration, + finishGeneration, + onStartGeneration, +} from "~/signals/generation/generationSignals"; + +export class GenerationEngine { + private isGenerating = false; + + constructor() { + console.log("GenerationEngine constructor"); + this.attachEventListeners(); + } + + private attachEventListeners() { + onStartGeneration((prompt) => { + this.invokeGeneration(prompt); + }); + } + + private async invokeGeneration(prompt: string) { + if (this.isGenerating) { + return; + } + + this.isGenerating = true; + console.log("Invoking generation for prompt:", prompt); + + try { + const base64BitmapResponse = await invoke("text_to_image", { + prompt: prompt, + }); + + console.log("Generation finished with response:", base64BitmapResponse); + + const base64string = base64BitmapResponse as string; + finishGeneration(base64string); + } catch (error) { + console.error("Error during image processing:", error); + cancelGeneration(); + } finally { + this.isGenerating = false; + } + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/BaseNode.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/BaseNode.ts new file mode 100644 index 0000000000..79ccfcc7f1 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/BaseNode.ts @@ -0,0 +1,253 @@ +import Konva from "konva"; + +import { SelectionManager } from "../NodesManagers"; +import { highlightStrokeWidth, primaryOrange } from "./constants"; +import { NodeData, Position } from "../types"; +import { RealTimeDrawEngine } from "../RenderingPrimitives/RealTimeDrawEngine"; +import { MouseEventHandler } from "react"; +import { KonvaEventObject } from "konva/lib/Node"; + +export type SupportedNode = Konva.Image | Konva.Group | Konva.Shape; + +export abstract class BaseNode { + protected selectionManagerRef: SelectionManager; + public kNode: SupportedNode; + protected mediaLayerRef: Konva.Layer; + + // Internal State members + // do not modify internal + protected isSelecting: boolean = false; + // This locks interaction when the render engine is rendering + protected isProcessing: boolean = false; + protected _isLocked: boolean = false; + protected _areBaseEventsListening: boolean = false; + protected _isKDragEventListening: boolean = false; + protected _isKTranformEventListening: boolean = false; + + abstract getNodeData(captureCanvasPosition: Position): NodeData | null; + + constructor({ + selectionManagerRef, + mediaLayerRef, + kNode, + }: { + selectionManagerRef: SelectionManager; + mediaLayerRef: Konva.Layer; + kNode: SupportedNode; + }) { + // console.log("Node constructed"); + // this.uuid = uuidv4(); + this.mediaLayerRef = mediaLayerRef; + this.selectionManagerRef = selectionManagerRef; + this.kNode = kNode; + } + + protected delete() { + // Do any other clean up and delete the konva node. + this.kNode.destroy(); + } + public async setProcessing(val: boolean) { + this.isProcessing = val; + } + public isKEventRef() { + return this._isKDragEventListening && this._isKTranformEventListening; + } + public setIsKEventRef(flag: boolean) { + this._isKDragEventListening = flag; + this._isKTranformEventListening = flag; + this.removeListenToBaseKNodeTransformations(); + this.removeListenToBaseKNodeDrags(); + if (flag) { + this.listenToBaseKNodeTransformations(); + this.listenToBaseKNodeDrags(); + } + } + public highlight() { + // console.log("Highlight", this.kNode._id); + if (this.kNode instanceof Konva.Image || this.kNode instanceof Konva.Shape) { + this.kNode.stroke(primaryOrange); + this.kNode.strokeWidth(highlightStrokeWidth); + return; + } + if (this.kNode instanceof Konva.Group) { + const wrapperRect = this.kNode.findOne(".wrapper") as Konva.Rect; + console.log("Wrapper rect:", wrapperRect); + if (wrapperRect != null) { + wrapperRect.stroke(primaryOrange); + wrapperRect.strokeWidth(highlightStrokeWidth); + } + return; + } + if (import.meta.env.DEV) { + console.warn( + "DEV: BaseNode Highlight Error, kNode of BaseNode should be override by MediaNodes that extends kNode", + ); + } + } + + public unhighlight() { + // console.log("unHighlight", this.kNode._id); + if (this.kNode instanceof Konva.Image || this.kNode instanceof Konva.Shape) { + this.kNode.strokeWidth(0); + return; + } + if (this.kNode instanceof Konva.Group) { + const wrapperRect = this.kNode.findOne(".wrapper") as Konva.Rect; + console.log("Wrapper rect:", wrapperRect); + if (wrapperRect != null) { + // Issue investigate + wrapperRect.strokeWidth(0); + } + return; + } + if (import.meta.env.DEV) { + console.warn( + "DEV: BaseNode UnHighlight Error, kNode of BaseNode should be override by MediaNodes that extends kNode", + ); + } + } + + public moveLayerUp() { + this.kNode.moveUp(); + } + public moveLayerDown() { + this.kNode.moveDown(); + } + public isLocked() { + return this._isLocked; + } + public lock() { + this._isLocked = true; + this.kNode.setDraggable(false); + this.selectionManagerRef.updateNodeTransformer(); + if (this.isKEventRef()) { + this.selectionManagerRef.updateContextComponents(); + } + } + public unlock() { + this._isLocked = false; + this.kNode.setDraggable(true); + this.selectionManagerRef.updateNodeTransformer(); + if (this.isKEventRef()) { + this.selectionManagerRef.updateContextComponents(); + } + } + + public listenToBaseKNode() { + if (this._areBaseEventsListening) { + return; + } + this._areBaseEventsListening = true; + const handleSelect = (isMultiSelect: boolean) => { + // console.log("handle select"); + if (!isMultiSelect) { + // clear selection if not multislect + console.log("No Shift >> no multiselect"); + this.selectionManagerRef.clearSelection(); + } + if (this.selectionManagerRef.isNodeSelected(this)) { + this.selectionManagerRef.deselectNode(this); + return; + } + if (this.isSelecting) { + this.selectionManagerRef.selectNode(this); + return; + } + }; + + const handleRightClick = (e: KonvaEventObject) => { + this.selectionManagerRef.clearSelection(); + this.selectionManagerRef.selectNode(this, undefined, { + show: true, + at: { x: e.evt.x, y: e.evt.y } + }); + } + + this.kNode.on("contextmenu", (e) => { + console.log("CONTEXT MENU", e); + e.evt.preventDefault(); + handleRightClick(e); + }); + + this.kNode.on("mousedown", (e) => { + // https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/which + // 3: Right mouse button + // If right click, let context event handle it + if (e.evt.button === 2) { + return; + } + + // Selection of Node + if (!this.selectionManagerRef.isNodeSelected(this)) { + this.isSelecting = true; + // send shift key to check for multiselect + handleSelect(e.evt.shiftKey); + } + }); + + this.kNode.on("mouseup", (e) => { + // https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/which + // 3: Right mouse button + // If right click, let context event handle it + if (e.evt.button === 2) { + return; + } + + // just coming out of dragging or selecting mode + // no need to handle select + if (this.selectionManagerRef.isDragging() || this.isSelecting) { + this.isSelecting = false; + return; + } + + // checking for multiselect, in multiselect deselection is possible + handleSelect(e.evt.shiftKey); + }); + } + public removeListenToBaseKNode() { + this._areBaseEventsListening = false; + this.kNode.removeEventListener("mousedown mouseup"); + } + private handleDragStart = () => { + console.log("Drag start", this.kNode._id); + if (this.isProcessing) { + return; + } + this.selectionManagerRef.dragStart(); + }; + + private handleDragEnd = () => { + this.selectionManagerRef.dragEnd(); + }; + + public listenToBaseKNodeDrags() { + this.kNode.on("dragstart", this.handleDragStart); + this.kNode.on("dragend", this.handleDragEnd); + } + + public removeListenToBaseKNodeDrags() { + this.kNode.off("dragstart", this.handleDragStart); + this.kNode.off("dragend", this.handleDragEnd); + } + public listenToBaseKNodeTransformations() { + this.kNode.on("transformstart", (event) => { + console.log("transformstart", event.target._id); + this.selectionManagerRef.transformStart(); + }); + this.kNode.on("transform", () => { + this.kNode.setAttrs({ + width: this.kNode.width() * this.kNode.scaleX(), + height: this.kNode.height() * this.kNode.scaleY(), + scaleX: 1, + scaleY: 1, + }); + }); + this.kNode.on("transformend", (event) => { + console.log("transformend", event.target._id); + this.selectionManagerRef.transformEnd(); + }); + } + public removeListenToBaseKNodeTransformations() { + this.kNode.removeEventListener("transformend"); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/ImageNode.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/ImageNode.ts new file mode 100644 index 0000000000..a81cdf7301 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/ImageNode.ts @@ -0,0 +1,261 @@ +import Konva from "konva"; +import { MediaFilesApi, MediaUploadApi } from "~/Classes/ApiManager"; + +import { SelectionManager } from "../NodesManagers"; +import { Position, Size, NodeData, TransformationData } from "../types"; + +import { NetworkedNode, UploadStatus } from "./NetworkedNode"; +import { NodeType, transparent } from "./constants"; +import { NodeUtilities } from "./NodeUtilities"; + +interface ImageNodeContructor { + canvasPosition: Position; + canvasSize: Size; + imageFile?: File; + imageBitmap?: ImageBitmap; + mediaFileToken?: string; + mediaFileUrl?: string; + transform?: TransformationData; + mediaLayerRef: Konva.Layer; + selectionManagerRef: SelectionManager; + loaded: () => Promise; +} + +export class ImageNode extends NetworkedNode { + public kNode: Konva.Image; + public imageSize?: Size; + private loaded!: () => Promise; + constructor({ + canvasPosition, + canvasSize, + imageFile, + mediaFileToken, + mediaFileUrl, + transform: existingTransform, + mediaLayerRef, + selectionManagerRef, + loaded, + imageBitmap, + }: ImageNodeContructor) { + // kNodes need to be created first to guaruntee + // that it is not undefined in parent's context + const transform = NodeUtilities.getInitialTransform({ + existingTransform, + canvasPosition, + canvasSize, + }); + const kNode = new Konva.Image({ + image: undefined, // to do replace with placeholder + ...transform, + draggable: true, + strokeScaleEnabled: false, + }); + super({ + selectionManagerRef: selectionManagerRef, + mediaLayerRef: mediaLayerRef, + kNode: kNode, + localFile: imageFile, + }); + this.kNode = kNode; + this.loaded = loaded; + // this.imageSize = minNodeSize; + this.mediaLayerRef.add(this.kNode); + + if (imageFile) { + this.loadImageFromFile({ + imageFile: imageFile, + maxSize: canvasSize, + refPosition: canvasPosition, + }); + return; + } + if (mediaFileUrl && transform) { + this.mediaFileToken = mediaFileToken; + this.mediaFileUrl = mediaFileUrl; + this.loadImageFromUrl(mediaFileUrl); + return; + } + if (imageBitmap) { + this.loadImageFromImageBitmap({ + imageBitmap: imageBitmap, + maxSize: canvasSize, + refPosition: canvasPosition, + }); + return; + } + console.log("image node creation is fucked"); + } + private loadImageFromFile({ + imageFile, + maxSize, + refPosition, + }: { + imageFile: File; + maxSize: Size; + refPosition: Position; + }) { + const imageComponent = new Image(); + imageComponent.crossOrigin = "anonymous"; + // this.setProgress({ progress: 100, status: UploadStatus.SUCCESS }); + + imageComponent.onload = () => { + //this.setProgress({ progress: 0, status: UploadStatus.FILE_STAGED }); + this.imageSize = { + width: imageComponent.width, + height: imageComponent.height, + }; + const adjustedSize = NodeUtilities.adjustNodeSizeToCanvas({ + componentSize: this.imageSize, + maxSize: maxSize, + }); + const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({ + canvasOffset: refPosition, + componentSize: adjustedSize, + maxSize: maxSize, + }); + this.kNode.image(imageComponent); + this.kNode.setSize(adjustedSize); + this.kNode.setPosition(centerPosition); + this.kNode.moveToTop(); + this.kNode.fill(transparent); + this.listenToBaseKNode(); + this.mediaLayerRef.draw(); + //this.uploadImage(imageFile); + this.loaded(); + }; + imageComponent.onerror = () => { + //this.setProgress({ progress: 0, status: UploadStatus.ERROR_ON_FILE }); + }; + imageComponent.src = URL.createObjectURL(imageFile); + } + private async loadImageFromUrl(mediaFileUrl: string) { + //this.setProgress({ progress: 75, status: UploadStatus.LOADING }); + + const newImage = new Image(); + newImage.crossOrigin = "anonymous"; + newImage.onerror = () => { + //this.setProgress({ progress: 90, status: UploadStatus.ERROR_ON_LOAD }); + }; + newImage.onload = () => { + // console.log("network image", newImage); + this.kNode.image(newImage); + this.kNode.draw(); + //this.setProgress({ progress: 100, status: UploadStatus.SUCCESS }); + }; + newImage.src = mediaFileUrl; + this.listenToBaseKNode(); + } + + private loadImageFromImageBitmap({ + imageBitmap, + maxSize, + refPosition, + }: { + imageBitmap: ImageBitmap; + maxSize: Size; + refPosition: Position; + }) { + this.imageSize = { + width: imageBitmap.width, + height: imageBitmap.height, + }; + const adjustedSize = NodeUtilities.adjustNodeSizeToCanvas({ + componentSize: this.imageSize, + maxSize: maxSize, // TODO + }); + const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({ + canvasOffset: refPosition, // TODO + componentSize: adjustedSize, // TODO + maxSize: maxSize, // TODO + }); + this.kNode.image(imageBitmap); + this.kNode.setSize(adjustedSize); + this.kNode.setPosition(centerPosition); + this.kNode.moveToTop(); + this.kNode.fill(transparent); + this.listenToBaseKNode(); + this.mediaLayerRef.draw(); + //this.uploadImage(imageFile); + this.loaded(); + this.listenToBaseKNode(); + } + + private async uploadImage(imageFile: File) { + //this.setProgress({ progress: 10, status: UploadStatus.UPLOADING }); + + const mediaUploadApi = new MediaUploadApi(); + const uploadResponse = await mediaUploadApi.UploadImage({ + blob: imageFile, + uuid: this.uuidGenerate(), + fileName: imageFile.name, + }); + // console.log(uploadResponse); + if (!uploadResponse.success || !uploadResponse.data) { + this.setStatus(UploadStatus.ERROR_ON_UPLOAD, uploadResponse.errorMessage); + return; + } + this.mediaFileToken = uploadResponse.data; + this.retreiveImage(this.mediaFileToken); + } + + private async retreiveImage(mediaFileToken: string) { + //this.setProgress({ progress: 50, status: UploadStatus.RETREIVING }); + const mediaFileApi = new MediaFilesApi(); + const mediaFileResponse = await mediaFileApi.GetMediaFileByToken({ + mediaFileToken: mediaFileToken, + }); + // console.log(mediaFileResponse); + if (!mediaFileResponse.success || !mediaFileResponse.data) { + this.setStatus( + UploadStatus.ERROR_ON_RETREIVE, + mediaFileResponse.errorMessage, + ); + return; + } + this.mediaFileUrl = mediaFileResponse.data.media_links.cdn_url; + this.loadImageFromUrl(this.mediaFileUrl); + } + + public async retry() { + if (this.mediaFileUrl) { + this.loadImageFromUrl(this.mediaFileUrl); + return; + } + if (this.mediaFileToken) { + this.retreiveImage(this.mediaFileToken); + return; + } + if (this.localFile) { + this.uploadImage(this.localFile); + return; + } + console.warn("Image Node has no data to recontruct itself!"); + } + public getNodeData(canvasPostion: Position) { + if (!this.mediaFileUrl) { + console.error("Image Node can not be saved"); + return null; + } + const data: NodeData = { + type: NodeType.IMAGE, + transform: { + position: { + x: this.kNode.position().x - canvasPostion.x, + y: this.kNode.position().y - canvasPostion.y, + }, + size: this.kNode.size(), + rotation: this.kNode.rotation(), + scale: { + x: this.kNode.scaleX(), + y: this.kNode.scaleY(), + }, + zIndex: this.kNode.getZIndex(), + }, + imageNodeData: { + mediaFileUrl: this.mediaFileUrl, + mediaFileToken: this.mediaFileToken, + }, + }; + return data; + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/NetworkedNode.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/NetworkedNode.ts new file mode 100644 index 0000000000..f552e3d2c5 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/NetworkedNode.ts @@ -0,0 +1,134 @@ +import Konva from "konva"; +import { v4 as uuidv4 } from "uuid"; +import { SelectionManager } from "../NodesManagers"; +import { BaseNode, SupportedNode } from "./BaseNode"; +import { Size } from "../types"; +import { LoadingVideosProvider } from "../EngineUtitlities/LoadingVideosProvider"; +import { NodeProgressEventDetail } from "../types/events"; + +export enum UploadStatus { + INIT = "init", + FILE_STAGED = "file_staged", + ERROR_ON_FILE = "error_on_file", + UPLOADING = "uploading", + ERROR_ON_UPLOAD = "error_on_upload", + RETREIVING = "retreiving", + ERROR_ON_RETREIVE = "error_on_retreive", + LOADING = "loading", + ERROR_ON_LOAD = "error_on_load", + SUCCESS = "success", +} + +export abstract class NetworkedNode extends BaseNode { + public kNode: SupportedNode; + + // members to deal with loading progress + public didFinishLoading: boolean = false; + protected _progress: number = 0; + protected _progressMessage?: string; + protected progressStatus: string = UploadStatus.INIT; + public progressEvent: EventTarget; + + // members to deal with the file carried + protected localFile?: File; + protected mediaFileToken?: string; + protected mediaFileUrl?: string; + public mediaFileSize?: Size; + + // loading screen + protected loadingVideosProviderRef?: LoadingVideosProvider; + + // error and retry handling + public errorMessage?: string; + abstract retry(): void; + + constructor({ + kNode, + selectionManagerRef, + mediaLayerRef, + localFile, + loadingVideosProviderRef, + }: { + kNode: SupportedNode; + selectionManagerRef: SelectionManager; + mediaLayerRef: Konva.Layer; + loadingVideosProviderRef?: LoadingVideosProvider; + localFile?: File; + }) { + super({ + kNode, + selectionManagerRef, + mediaLayerRef, + }); + this.kNode = kNode; + this.loadingVideosProviderRef = loadingVideosProviderRef; + this.didFinishLoading = false; + this.progressEvent = new EventTarget(); + this.localFile = localFile; + } + public progress() { + return this._progress; + } + public progressMessage() { + return this._progressMessage; + } + public status() { + return this.progressStatus; + } + public isError() { + const errorStatues: string[] = [ + UploadStatus.ERROR_ON_FILE, + UploadStatus.ERROR_ON_UPLOAD, + UploadStatus.ERROR_ON_RETREIVE, + UploadStatus.ERROR_ON_LOAD, + ]; + return errorStatues.includes(this.progressStatus); + } + + protected uuidGenerate() { + return uuidv4(); + } + protected setStatus(newStatus: string, message?: string) { + this.progressStatus = newStatus; + this.errorMessage = message; + this.selectionManagerRef.updateContextComponents(); + } + protected setProgress({ + progress, + name, + status, + message, + }: { + progress: number; + name?: string; + status?: S; + message?: string; + }) { + this._progress = progress; + if (message) { + this._progressMessage = message; + } + if (status && typeof status === "string") { + this.progressStatus = status; + } + + if (this._progress === 100) { + this.didFinishLoading = true; + } + // TODO: change to use + this.progressEvent.dispatchEvent( + new CustomEvent>( + name ? name : "GenericNodeProgress", + { + detail: { + node: this, + progress: this._progress, + message: message, + status: status, + }, + }, + ), + ); + this.selectionManagerRef.updateContextComponents(); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/NodeUtilities.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/NodeUtilities.ts new file mode 100644 index 0000000000..327c9ca35b --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/NodeUtilities.ts @@ -0,0 +1,158 @@ +import Konva from "konva"; +import { Position, Size, TransformationData } from "../types"; +import { minNodeSize, transparent } from "./constants"; + +export const NodeUtilities = { + adjustNodeSizeToCanvas, + downloadOffscreenCanvas, + getInitialTransform, + isAssetUrlAvailable, + positionNodeOnCanvasCenter, + printKNodeAttrs, + urlToBlob, +}; +function downloadOffscreenCanvas(canvas: OffscreenCanvas) { + canvas.convertToBlob().then((blob) => { + if (blob) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "offscreen-canvas-image.png"; // Specify the file name + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); // Clean up + } + }); +} +function getInitialTransform({ + existingTransform, + mediaFileSize, + canvasPosition, + canvasSize, +}: { + existingTransform?: TransformationData; + mediaFileSize?: Size; + canvasPosition: Position; + canvasSize: Size; +}) { + return existingTransform + ? { + ...existingTransform, + position: { + x: existingTransform.position.x + canvasPosition.x, + y: existingTransform.position.y + canvasPosition.y, + }, + fill: transparent, + } + : { + position: positionNodeOnCanvasCenter({ + canvasOffset: canvasPosition, + componentSize: mediaFileSize ?? minNodeSize, + maxSize: canvasSize, + }), + size: mediaFileSize ?? minNodeSize, + fill: "gray", + }; +} + +function adjustNodeSizeToCanvas({ + componentSize, + maxSize, +}: { + componentSize: Size; + maxSize: Size; +}) { + const adjustedSize = { + width: componentSize.width, + height: componentSize.height, + }; + if (adjustedSize.width > maxSize.width) { + const scaleDown = maxSize.width / adjustedSize.width; + adjustedSize.width = adjustedSize.width * scaleDown; + adjustedSize.height = adjustedSize.height * scaleDown; + } + if (adjustedSize.height > maxSize.height) { + const scaleDownAgain = maxSize.height / adjustedSize.height; + adjustedSize.width = adjustedSize.width * scaleDownAgain; + adjustedSize.height = adjustedSize.height * scaleDownAgain; + } + return adjustedSize; +} + +function positionNodeOnCanvasCenter({ + canvasOffset, + componentSize, + maxSize, +}: { + canvasOffset: Position; + componentSize: Size; + maxSize: Size; +}) { + return { + x: canvasOffset.x + maxSize.width / 2 - componentSize.width / 2, + y: canvasOffset.y + maxSize.height / 2 - componentSize.height / 2, + }; +} + +function printKNodeAttrs(kNode: Konva.Node) { + console.log({ + position: kNode.position(), + size: kNode.size(), + scale: kNode.scale(), + rotation: kNode.rotation(), + }); +} +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Function to check if the URL returns a 200 status +async function checkUrl(url: string): Promise { + try { + const response = await fetch(url); + return response.status === 200; + } catch (error) { + console.error("Error fetching URL:", error); + return false; + } +} +// wait using a while loop to display the result. +async function isAssetUrlAvailable({ + url, + sleepDurationMs = 500, + totalRetries, +}: { + url: string; + sleepDurationMs?: number; + totalRetries?: number; +}): Promise { + let retryCount = 0; + let isAvailable = false; + const totalRetriesReached = () => { + if (!totalRetries) { + return false; + } + return retryCount >= totalRetries; + }; + while (!isAvailable && !totalRetriesReached()) { + isAvailable = await checkUrl(url); + if (!isAvailable) { + console.log("Asset at Url not available yet, retrying..."); + retryCount = retryCount + 1; + await sleep(sleepDurationMs); + } else if (import.meta.env.DEV) { + console.log("Preview Image is available:", url); + } + } + return isAvailable; +} + +async function urlToBlob(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch resource: ${response.statusText}`); + } + const blob = await response.blob(); + return blob; +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PaintNode.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PaintNode.ts new file mode 100644 index 0000000000..dd6e221b4b --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PaintNode.ts @@ -0,0 +1,91 @@ +import Konva from "konva"; +import { SelectionManager } from "../NodesManagers"; +import { Position, NodeData } from "../types"; +import { NodeType } from "./constants"; + +import { NetworkedNode } from "./NetworkedNode"; + +interface PaintNodeConstructor { + canvasElement: HTMLCanvasElement; + lineBounds: { + width: number; + height: number; + x: number; + y: number; + }; + mediaLayerRef: Konva.Layer; + selectionManagerRef: SelectionManager; + + loaded: () => Promise; +} + +export class PaintNode extends NetworkedNode { + retry(): void { + throw new Error("Method not implemented."); + } + + constructor({ + canvasElement, + lineBounds, + mediaLayerRef, + selectionManagerRef, + loaded, + }: PaintNodeConstructor) { + const cl = canvasElement; + + // Convert circle to image + const dataURL = cl.toDataURL(); + const imageObj = new Image(); + imageObj.src = dataURL; + + const paintShape = new Konva.Image({ + x: lineBounds.x, + y: lineBounds.y, + width: lineBounds.width, + height: lineBounds.height, + image: imageObj, + listening: false, // Disable event listening to allow captureCanvas to receive events + draggable: true, + // stroke: '#000000', + // strokeWidth: 2, + // dash: [5, 5], // Creates dotted outline THIS IS FOR DEBUG + globalCompositeOperation: "source-over", + fill: "transparent", + }); + + imageObj.onload = () => { + this.mediaLayerRef.draw(); + loaded(); + }; + + super({ + selectionManagerRef: selectionManagerRef, + mediaLayerRef: mediaLayerRef, + kNode: paintShape, + }); + this.kNode.moveToTop(); + this.mediaLayerRef.add(this.kNode); + this.listenToBaseKNode(); + this.mediaLayerRef.draw(); + } + + public getNodeData(canvasPosition: Position) { + const data: NodeData = { + type: NodeType.SHAPE, + transform: { + position: { + x: this.kNode.position().x - canvasPosition.x, + y: this.kNode.position().y - canvasPosition.y, + }, + size: this.kNode.size(), + rotation: this.kNode.rotation(), + scale: { + x: this.kNode.scaleX(), + y: this.kNode.scaleY(), + }, + zIndex: this.kNode.getZIndex(), + }, + }; + return data; + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PreviewCopy.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PreviewCopy.ts new file mode 100644 index 0000000000..7fa8da25cc --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PreviewCopy.ts @@ -0,0 +1,58 @@ +import Konva from "konva"; +import { SelectionManager } from "../NodesManagers"; +import { Position, Size, NodeData, TransformationData } from "../types"; +import { NodeType } from "./constants"; +import { NodeUtilities } from "./NodeUtilities"; +import { NetworkedNode } from "./NetworkedNode"; + +interface PreviewCopyConstructor { + image: Konva.Image; + mediaLayerRef: Konva.Layer; + selectionManagerRef: SelectionManager; + loaded: () => Promise; +} + +export class PreviewCopyNode extends NetworkedNode { + retry(): void { + throw new Error("Method not implemented."); + } + + constructor({ + image, + mediaLayerRef, + selectionManagerRef, + loaded, + }: PreviewCopyConstructor) { + // Create the actual shape inside the group + + super({ + selectionManagerRef: selectionManagerRef, + mediaLayerRef: mediaLayerRef, + kNode: image, + }); + this.kNode.moveToTop(); + this.mediaLayerRef.add(this.kNode); + this.listenToBaseKNode(); + this.mediaLayerRef.draw(); + } + + public getNodeData(canvasPosition: Position) { + const data: NodeData = { + type: NodeType.SHAPE, + transform: { + position: { + x: this.kNode.position().x - canvasPosition.x, + y: this.kNode.position().y - canvasPosition.y, + }, + size: this.kNode.size(), + rotation: this.kNode.rotation(), + scale: { + x: this.kNode.scaleX(), + y: this.kNode.scaleY(), + }, + zIndex: this.kNode.getZIndex(), + }, + }; + return data; + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PureTextNode.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PureTextNode.ts new file mode 100644 index 0000000000..1d79ebb6eb --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/PureTextNode.ts @@ -0,0 +1,186 @@ +import Konva from "konva"; +import { SelectionManager } from "../NodesManagers"; +import { BaseNode } from "./BaseNode"; +import { + NodeData, + Position, + Size, + TextNodeChildrenTransformData, + TextNodeData, + TransformationData, +} from "../types"; +import { NodeUtilities } from "./NodeUtilities"; +import { NodeType } from "./constants"; + +export class PureTextNode extends BaseNode { + public kNode: Konva.Group; + public rectNode: Konva.Rect; + public textNode: Konva.Text; + private originalTextSize: Size; + private textNodeData: TextNodeData; + + public didFinishLoading: boolean = true; + + constructor({ + textNodeData, + selectionManagerRef, + mediaLayerRef, + canvasPosition, + canvasSize, + transform: existingTransform, + textChildrenTransforms, + }: { + textNodeData: TextNodeData; + selectionManagerRef: SelectionManager; + mediaLayerRef: Konva.Layer; + canvasSize: Size; + canvasPosition: Position; + transform?: TransformationData; + textChildrenTransforms?: TextNodeChildrenTransformData; + }) { + const textNode = new Konva.Text({ + ...textNodeData, + x: 10, + y: 10, + width: 500, + }); + const kNode = new Konva.Group({ + position: NodeUtilities.positionNodeOnCanvasCenter({ + canvasOffset: canvasPosition, + componentSize: { + width: textNode.width() + 20, + height: textNode.height() + 20, + }, + maxSize: canvasSize, + }), + width: textNode.width() + 20, + height: textNode.height() + 20, + draggable: true, + }); + const rectNode = new Konva.Rect({ + x: 0, + y: 0, + width: textNode.width() + 20, + height: textNode.height() + 20, + strokeScaleEnabled: false, + name: "wrapper", + }); + + super({ + selectionManagerRef: selectionManagerRef, + mediaLayerRef: mediaLayerRef, + kNode: kNode, + }); + this.textNodeData = textNodeData; + this.kNode = kNode; + this.textNode = textNode; + this.rectNode = rectNode; + this.kNode.add(this.rectNode); + this.kNode.add(this.textNode); + if (existingTransform && textChildrenTransforms) { + this.applyTransform({ + existingTransform, + textChildrenTransforms, + canvasPosition, + }); + } + this.mediaLayerRef.add(this.kNode); + this.originalTextSize = this.textNode.size(); + this.listenToBaseKNode(); + } + public listenToBaseKNodeTransformations() { + this.kNode.on("transformstart", () => { + // console.log("transformstart", event.target._id); + this.selectionManagerRef.transformStart(); + }); + + this.kNode.on("transform", () => { + const newBoxSize = { + width: this.kNode.width() * this.kNode.scaleX(), + height: this.kNode.height() * this.kNode.scaleY(), + scaleX: 1, + scaleY: 1, + }; + this.rectNode.setAttrs(newBoxSize); + this.kNode.setAttrs(newBoxSize); + const scaleX = (newBoxSize.width - 20) / this.originalTextSize.width; + const scaleY = (newBoxSize.height - 20) / this.originalTextSize.height; + this.textNode.setAttrs({ + // make sure these stay the same + x: 10, + y: 10, + size: this.originalTextSize, + //while these changes in size + scaleX: scaleX, + scaleY: scaleY, + }); + }); + this.kNode.on("transformend", () => { + // console.log("transformend", event.target._id); + NodeUtilities.printKNodeAttrs(this.kNode); + NodeUtilities.printKNodeAttrs(this.textNode); + this.selectionManagerRef.transformEnd(this); + }); + } + public getNodeData(canvasPostion: Position) { + const data: NodeData = { + type: NodeType.TEXT, + transform: { + position: { + x: this.kNode.position().x - canvasPostion.x, + y: this.kNode.position().y - canvasPostion.y, + }, + size: this.kNode.size(), + rotation: this.kNode.rotation(), + scale: { + x: this.kNode.scaleX(), + y: this.kNode.scaleY(), + }, + zIndex: this.kNode.getZIndex(), + }, + textNodeData: this.textNodeData, + textChildrenTransforms: { + wrapperRectTransform: { + position: this.rectNode.position(), + size: this.rectNode.size(), + rotation: this.rectNode.rotation(), + scale: { + x: this.rectNode.scaleX(), + y: this.rectNode.scaleY(), + }, + zIndex: this.rectNode.getZIndex(), + }, + textNodeTransform: { + position: this.textNode.position(), + size: this.textNode.size(), + rotation: this.textNode.rotation(), + scale: { + x: this.textNode.scaleX(), + y: this.textNode.scaleY(), + }, + zIndex: this.textNode.getZIndex(), + }, + }, + }; + return data; + } + private applyTransform({ + canvasPosition, + existingTransform, + textChildrenTransforms, + }: { + canvasPosition: Position; + existingTransform: TransformationData; + textChildrenTransforms: TextNodeChildrenTransformData; + }) { + this.kNode.setAttrs({ + ...existingTransform, + position: { + x: existingTransform.position.x + canvasPosition.x, + y: existingTransform.position.y + canvasPosition.y, + }, + }); + this.rectNode.setAttrs(textChildrenTransforms.wrapperRectTransform); + this.textNode.setAttrs(textChildrenTransforms.textNodeTransform); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/ShapeNode.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/ShapeNode.ts new file mode 100644 index 0000000000..4293a7d623 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/ShapeNode.ts @@ -0,0 +1,152 @@ +import Konva from "konva"; +import { SelectionManager } from "../NodesManagers"; +import { Position, Size, NodeData, TransformationData } from "../types"; +import { NodeType } from "./constants"; +import { NodeUtilities } from "./NodeUtilities"; +import { NetworkedNode } from "./NetworkedNode"; + +export enum ShapeType { + CIRCLE = "circle", + SQUARE = "square", + TRIANGLE = "triangle", +} + +interface ShapeNodeConstructor { + canvasPosition: Position; + canvasSize: Size; + shapeType: ShapeType; + size: Size; + color?: string; // Hex color string + transform?: TransformationData; + mediaLayerRef: Konva.Layer; + selectionManagerRef: SelectionManager; + loaded: () => Promise; +} + +export class ShapeNode extends NetworkedNode { + retry(): void { + throw new Error("Method not implemented."); + } + + public shapeType: ShapeType; + public size: Size; + public color: string; + + constructor({ + canvasPosition, + canvasSize, + shapeType, + size, + color = "#2D81FF", // Default red if no color provided + transform: existingTransform, + mediaLayerRef, + selectionManagerRef, + loaded, + }: ShapeNodeConstructor) { + // Create the actual shape inside the group + const transform = NodeUtilities.getInitialTransform({ + existingTransform, + mediaFileSize: size, + canvasPosition, + canvasSize, + }); + + let shape = null; + + switch (shapeType) { + case ShapeType.CIRCLE: + shape = new Konva.Ellipse({ + radiusX: size.width / 2, + radiusY: size.height / 2, + fill: color, + strokeScaleEnabled: false, + draggable: true, + }); + break; + + case ShapeType.SQUARE: + shape = new Konva.Rect({ + width: size.width, + height: size.height, + ...transform, + fill: color, + strokeScaleEnabled: false, + draggable: true, + }); + break; + + case ShapeType.TRIANGLE: + shape = new Konva.Shape({ + width: size.width, + height: size.height, + ...transform, + fill: color, + strokeScaleEnabled: false, + draggable: true, + sceneFunc: (context, shape) => { + context.beginPath(); + context.moveTo(shape.width() / 2, 0); + context.lineTo(shape.width(), shape.height()); + context.lineTo(0, shape.height()); + context.closePath(); + context.fillStrokeShape(shape); + } + }); + break; + + default: + throw new Error("Invalid shape type"); + } + + super({ + selectionManagerRef: selectionManagerRef, + mediaLayerRef: mediaLayerRef, + kNode: shape, + }); + + this.shapeType = shapeType; + this.size = size; + this.color = color; + + // Center the shape + const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({ + canvasOffset: canvasPosition, + componentSize: this.size, + maxSize: canvasSize, + }); + + // If the shape is an ellipse, the center is the origin so we need to offset it + if (shapeType === ShapeType.CIRCLE) { + centerPosition.x += size.width / 2; + centerPosition.y += size.height / 2; + } + + // Add shape to group + // bring to the front + this.kNode.moveToTop(); + this.kNode.setPosition(centerPosition); + this.mediaLayerRef.add(this.kNode); + this.listenToBaseKNode(); + this.mediaLayerRef.draw(); + } + + public getNodeData(canvasPosition: Position) { + const data: NodeData = { + type: NodeType.SHAPE, + transform: { + position: { + x: this.kNode.position().x - canvasPosition.x, + y: this.kNode.position().y - canvasPosition.y, + }, + size: this.kNode.size(), + rotation: this.kNode.rotation(), + scale: { + x: this.kNode.scaleX(), + y: this.kNode.scaleY(), + }, + zIndex: this.kNode.getZIndex(), + }, + }; + return data; + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/TextNode.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/TextNode.ts new file mode 100644 index 0000000000..114c5a6fcc --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/TextNode.ts @@ -0,0 +1,168 @@ +import Konva from "konva"; +import { SelectionManager } from "../NodesManagers"; +import { BaseNode } from "./BaseNode"; +import { + NodeData, + Position, + Size, + TextAlign, + TextNodeData, + TransformationData, +} from "../types"; +import { NodeUtilities } from "./NodeUtilities"; +import { NodeType, transparent } from "./constants"; + +export class TextNode extends BaseNode { + public kNode: Konva.Image; + private textNodeData: TextNodeData; + private originalTextSize?: Size; + public didFinishLoading: boolean = true; + + constructor({ + textNodeData, + selectionManagerRef, + mediaLayerRef, + canvasPosition, + canvasSize, + transform: existingTransform, + }: { + textNodeData: TextNodeData; + selectionManagerRef: SelectionManager; + mediaLayerRef: Konva.Layer; + canvasSize: Size; + canvasPosition: Position; + transform?: TransformationData; + }) { + const transform = NodeUtilities.getInitialTransform({ + existingTransform, + canvasPosition, + canvasSize, + }); + // Create a new ImageNode to house the canvas text + const kNode = new Konva.Image({ + image: undefined, // will use the canvas as the image source + ...transform, + draggable: true, + strokeScaleEnabled: false, + }); + + super({ + selectionManagerRef: selectionManagerRef, + mediaLayerRef: mediaLayerRef, + kNode: kNode, + }); + this.textNodeData = textNodeData; + this.kNode = kNode; + this.mediaLayerRef.add(this.kNode); + this.listenToBaseKNode(); + + this.loadTextImageUsingCanvas({ + maxSize: canvasSize, + refPosition: canvasPosition, + existingTransform: existingTransform, + }); + } + + private loadTextImageUsingCanvas({ + maxSize, + refPosition, + existingTransform, + }: { + maxSize: Size; + refPosition: Position; + existingTransform?: TransformationData; + }) { + // Create a temporary canvas to draw the text + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (context === null) { + return; + } + + // calvulate and set canvas + const lines: string[] = this.textNodeData.text.split("\n"); + const { fontSize, fontFamily, fontStyle, fontWeight, maxWidth, textAlign } = + this.textNodeData; + this.originalTextSize = { + width: maxWidth, + height: fontSize * lines.length * 1.5 + fontSize * 0.5, + }; + canvas.width = this.originalTextSize.width; + canvas.height = this.originalTextSize.height; + + // Set the font styles to match the text node + context.font = `${fontWeight} ${fontStyle} ${fontSize}px ${fontFamily}`; + + context.fillStyle = this.textNodeData.color; + context.textAlign = textAlign; + context.textBaseline = "top"; + + // Draw the text on the canvas + lines.forEach((line, idx) => { + const yOffset = fontSize * (1.5 * idx + 0.5); + const xOffset = + textAlign === TextAlign.LEFT + ? 0 + : textAlign === TextAlign.CENTER + ? maxWidth / 2 + : maxWidth; // textAlign === TextAlign.Right + + context.fillText(line, xOffset, yOffset); + }); + + // Convert canvas to data URL + const dataURL = canvas.toDataURL("image/png"); + + // Create a new Image object + const imageComponent = new Image(); + imageComponent.crossOrigin = "anonymous"; + imageComponent.onload = () => { + if (existingTransform) { + this.kNode.setAttrs({ + image: imageComponent, + fill: transparent, + }); + return; + } + const imageSize = { + width: canvas.width, + height: canvas.height, + }; + + const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({ + canvasOffset: refPosition, + componentSize: imageSize, + maxSize: maxSize, + }); + + this.kNode.setAttrs({ + image: imageComponent, + size: imageSize, + position: centerPosition, + fill: transparent, + }); + }; + imageComponent.src = dataURL; + } + + public getNodeData(canvasPostion: Position) { + const data: NodeData = { + type: NodeType.TEXT, + transform: { + position: { + x: this.kNode.position().x - canvasPostion.x, + y: this.kNode.position().y - canvasPostion.y, + }, + size: this.kNode.size(), + rotation: this.kNode.rotation(), + scale: { + x: this.kNode.scaleX(), + y: this.kNode.scaleY(), + }, + zIndex: this.kNode.getZIndex(), + }, + textNodeData: this.textNodeData, + }; + return data; + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/constants.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/constants.ts new file mode 100644 index 0000000000..c2a1fcc1c8 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/constants.ts @@ -0,0 +1,16 @@ +import { Colors } from "../constants"; + +export enum NodeType { + IMAGE = "image", + TEXT = "text", + VIDEO = "video", + SHAPE = "shape", +} +export const transparent = Colors.transparent; +export const primaryOrange = Colors.primaryOrange; + +export const highlightStrokeWidth = 2; +export const minNodeSize = { + width: 200, + height: 200, +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/index.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/index.ts new file mode 100644 index 0000000000..2f483b78e4 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/Nodes/index.ts @@ -0,0 +1,7 @@ +export { NodeType } from "./constants"; +export { BaseNode } from "./BaseNode"; +export { ImageNode } from "./ImageNode"; +export { PreviewCopyNode } from "./PreviewCopy"; +export { TextNode } from "./TextNode"; +export { ShapeNode , ShapeType} from "./ShapeNode"; +export { PaintNode } from "./PaintNode"; \ No newline at end of file diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodeIsolator.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodeIsolator.ts new file mode 100644 index 0000000000..d112a47eab --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodeIsolator.ts @@ -0,0 +1,122 @@ +import Konva from "konva"; +import { MediaNode, TransformationData } from "../types"; +import { uiAccess } from "~/signals"; + +export class NodeIsolator { + private mediaLayerRef: Konva.Layer; + private backgroundRect: Konva.Rect; + private referenceRect: Konva.Rect; + private nodeIsolationLayerRef: Konva.Layer; + private currentNode: MediaNode | undefined; + private originalKNodeTransformation: TransformationData | undefined; + private adjustSizeFnRef: (() => void) | undefined; + + constructor({ + mediaLayerRef, + nodeIsolationLayerRef, + }: { + mediaLayerRef: Konva.Layer; + nodeIsolationLayerRef: Konva.Layer; + }) { + this.mediaLayerRef = mediaLayerRef; + this.nodeIsolationLayerRef = nodeIsolationLayerRef; + this.backgroundRect = new Konva.Rect({ + fill: "rgba(0,0,0,0.5)", + }); + this.referenceRect = new Konva.Rect({ + fill: "gray", + stroke: "salmon", + strokeWidth: 10, + dash: [20, 10], + strokeScaleEnabled: false, + }); + } + + private adjustSizes() { + this.backgroundRect.setAttrs({ + width: window.innerWidth, + height: window.innerHeight, + }); + + if (this.currentNode && this.originalKNodeTransformation) { + this.referenceRect.setAttrs(this.originalKNodeTransformation); + + const originalSize = { + width: + this.originalKNodeTransformation.size.width * + this.originalKNodeTransformation.scale.x, + height: + this.originalKNodeTransformation.size.height * + this.originalKNodeTransformation.scale.y, + }; + const maxWidth = window.innerWidth * 0.8; + const isolationResize = { + width: maxWidth, + height: (maxWidth / originalSize.width) * originalSize.height, + }; + const maxHeight = window.innerHeight - 350; + if (isolationResize.height > maxHeight) { + isolationResize.height = maxHeight; + isolationResize.width = + (maxHeight / originalSize.height) * originalSize.width; + } + const isolationReposition = { + x: (window.innerWidth - isolationResize.width) / 2, + y: (maxHeight - isolationResize.height) / 2 + 100, + }; + this.currentNode.kNode.setAttrs({ + scale: { x: 1, y: 1 }, + rotation: 0, + size: isolationResize, + position: isolationReposition, + }); + } + } + public enterIsolation(node: MediaNode) { + // console.log("NodeIsolator > enterIsolation"); + uiAccess.toolbarMain.disable(); + this.currentNode = node; + this.adjustSizeFnRef = () => this.adjustSizes(); + this.preserveKNodeTransformation(node.kNode); + + this.currentNode.kNode.remove(); + this.nodeIsolationLayerRef.add(this.backgroundRect); + this.nodeIsolationLayerRef.add(this.currentNode.kNode); + this.mediaLayerRef.add(this.referenceRect); + this.adjustSizeFnRef(); + + window.addEventListener("resize", this.adjustSizeFnRef); + } + + public exitIsolation() { + // console.log("NodeIsolator > exitIsolation"); + if (this.adjustSizeFnRef) { + window.removeEventListener("resize", this.adjustSizeFnRef); + this.adjustSizeFnRef = undefined; + } + if (!this.currentNode) { + console.error("NodeIsolator lost crrent node before isolation!!"); + return; + } + this.currentNode.kNode.remove(); + this.backgroundRect.remove(); + this.referenceRect.remove(); + this.mediaLayerRef.add(this.currentNode.kNode); + this.currentNode.kNode.setAttrs(this.originalKNodeTransformation); + this.currentNode = undefined; + this.originalKNodeTransformation = undefined; + uiAccess.toolbarMain.enable(); + } + private preserveKNodeTransformation(kNode: Konva.Node) { + this.originalKNodeTransformation = { + position: kNode.position(), + size: kNode.size(), + rotation: kNode.rotation(), + scale: { + x: kNode.scaleX(), + y: kNode.scaleY(), + }, + zIndex: kNode.zIndex(), + }; + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodeTransformer.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodeTransformer.ts new file mode 100644 index 0000000000..0d105105b1 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodeTransformer.ts @@ -0,0 +1,61 @@ +import Konva from "konva"; +import { MediaNode } from "../types"; + +export class NodeTransformer { + private kTransformer: Konva.Transformer; + + constructor() { + this.kTransformer = new Konva.Transformer({ + padding: 0, + anchorStyleFunc: (anchor) => { + // anchor is Konva.Rect instance + // you manually change its styling + anchor.cornerRadius(10); + if (anchor.hasName("top-center") || anchor.hasName("bottom-center")) { + anchor.height(6); + anchor.offsetY(3); + anchor.width(30); + anchor.offsetX(15); + } + if (anchor.hasName("middle-left") || anchor.hasName("middle-right")) { + anchor.height(30); + anchor.offsetY(15); + anchor.width(6); + anchor.offsetX(3); + } + // if (anchor.hasName("rotater")) { + // anchor.offsetY(-25); + // } + // you also can set other properties + // e.g. you can set fillPatternImage to set icon to the anchor + }, + }); + } + public getKonvaNode() { + return this.kTransformer; + } + public addNodes({ selectedNodes }: { selectedNodes: Set }) { + const kNodesArray = Array.from(selectedNodes).reduce((acc, node) => { + acc.push(node.kNode); + return acc; + }, [] as Konva.Node[]); + + //if there are more things in the ui layer, turn this on + // this.kTransformer.moveToTop(); + + this.kTransformer.nodes(kNodesArray); + } + public clear() { + this.kTransformer.nodes([]); + } + public enable() { + this.kTransformer.rotateEnabled(true); + this.kTransformer.resizeEnabled(true); + this.kTransformer.draggable(true); + } + public disable() { + this.kTransformer.rotateEnabled(false); + this.kTransformer.resizeEnabled(false); + this.kTransformer.draggable(false); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodesManager.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodesManager.ts new file mode 100644 index 0000000000..f86afc2018 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/NodesManager.ts @@ -0,0 +1,63 @@ +import { MediaNode } from "../types"; + +export class NodesManager { + private allNodes: Set; + private allZIndices: Map; + + constructor() { + this.allNodes = new Set(); + this.allZIndices = new Map(); + } + public getAllNodes() { + return this.allNodes; + } + + public getAllZIndices() { + return this.allZIndices; + } + public getSortedZIndices() { + return Array.from(this.allZIndices).sort( + (nodeA, nodeB) => nodeA[1] - nodeB[1], + ); + } + public updateAllZIndices() { + this.allNodes.forEach((node) => { + this.allZIndices.set(node, node.kNode.getZIndex()); + }); + } + + public saveNode(node: MediaNode): void { + if (this.allNodes.has(node)) { + return; + } + this.allNodes.add(node); + this.updateAllZIndices(); + // this.printNodesAndZIndices(); + } + + public removeNode(node: MediaNode): void { + if (this.allNodes.has(node)) { + this.allNodes.delete(node); + this.allZIndices.delete(node); + this.updateAllZIndices(); + } + // this.printNodesAndZIndices(); + } + + public hasNode(node: MediaNode): boolean { + return this.allNodes.has(node); + } + + /************************* + * For Debugging use + */ + public printNodesAndZIndices() { + console.log(this.allNodes); + console.log(this.allZIndices); + const trueZIndices = Array.from(this.allNodes).map((node) => ({ + id: node.kNode._id, + z: node.kNode.getZIndex(), + })); + console.log("True ZIndces", trueZIndices); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/SelectionManager.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/SelectionManager.ts new file mode 100644 index 0000000000..dbbbd50f17 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/SelectionManager.ts @@ -0,0 +1,397 @@ +import Konva from "konva"; +import { NodeTransformer } from "./NodeTransformer"; +import { MediaNode, Position, Transformation } from "../types"; +import { uiAccess, uiEvents } from "../../signals"; +import { NetworkedNode } from "../Nodes/NetworkedNode"; +import { LoadingBarStatus } from "../../components/ui"; +import { ImageNode, ShapeNode, TextNode, PreviewCopyNode } from "../Nodes"; +import { + calculateContextualsPosition, + getImageNodeButtonStates, + getMultiSelectButtonStates, getPreviewCopyNodeButtonStates, + getShapeNodeButtonStates, + getTextNodeButtonStates, + getVideoNodeButtonStates, +} from "./ToolbarNodeUtilities"; + +export enum SelectionManagerEvents { + NODES_TRANSLATIONS = "nodestranslation", + NODES_TRANSFORMATION = "nodestransformation", +} +export interface NodesTranslationEventDetails { + nodes: Set; + initialPositions: Map; + finalPositions: Map; +} +export interface NodeTransformationEventDetails { + nodes: Set; + initialTransformations: Map; + finalTransformations: Map; +} + +export class SelectionManager { + private selectedNodes: Set; + private mediaLayerRef: Konva.Layer; + private nodeTransformerRef: NodeTransformer; + private initialPositions: Map; + private initialTransformations: Map; + public eventTarget: EventTarget; + private _isDragging: boolean = false; + private disabled: boolean = false; + + public firstSelectedNode: MediaNode | undefined; + + constructor({ + mediaLayerRef, + nodeTransformerRef, + }: { + mediaLayerRef: Konva.Layer; + nodeTransformerRef: NodeTransformer; + }) { + this.firstSelectedNode = undefined; + this.selectedNodes = new Set(); + + this.initialPositions = new Map(); + this.initialTransformations = new Map(); + this.eventTarget = new EventTarget(); + this.mediaLayerRef = mediaLayerRef; + this.nodeTransformerRef = nodeTransformerRef; + } + // This lets us perfom operations on the selected node. + public getSelectedNodes(): Set { + return this.selectedNodes; + } + public selectNodes(nodes: MediaNode[]) { + const unlockedNodes = nodes.filter((node) => { + return !node.isLocked(); + }); + unlockedNodes.forEach((node) => { + this.selectNode(node, true); + }); + this.mediaLayerRef.batchDraw(); + } + public disable() { + this.clearSelection(); + this.disabled = true; + } + public enable() { + this.disabled = false; + } + public isDisabled() { + return this.disable; + } + public selectNode(node: MediaNode, doNotDraw?: boolean, contextMenu: { + show?: boolean, + at?: { x: number, y: number } + } = { + show: false, + }): boolean { + if ( + this.disabled || // no-op when disabled + this.selectedNodes.has(node) || // if the node is already selected + (this.selectedNodes.size > 0 && node.isLocked()) || + // if in multiselect but picked a locked item + (this.selectedNodes.size > 0 && + this.selectedNodes.values().next().value?.isLocked()) + // if the first node picked is a locked item + ) { + return false; + } + + node.highlight(); + if (this.selectedNodes.size === 0) { + node.setIsKEventRef(true); + } + this.selectedNodes.add(node); + this.updateNodeTransformer(); + this.updateContextComponents(); + + console.log("SelectionManager > selectNode > showContextMenu", contextMenu.show); + if (contextMenu.show) { + this.showContextComponents(contextMenu.at); + } + + if (!doNotDraw) { + this.mediaLayerRef.batchDraw(); + } + return true; + } + + public deselectNode(node: MediaNode, doNotDraw?: boolean): void { + if (this.disabled) { + // no-op when disabled + return; + } + node.unhighlight(); + if (node.isKEventRef()) { + node.setIsKEventRef(false); + } + this.selectedNodes.delete(node); + this.updateNodeTransformer(); + this.updateContextComponents(); + this.showContextComponents(); + if (!doNotDraw) { + this.mediaLayerRef.batchDraw(); + } + } + + public clearSelection(): void { + if (this.disabled) { + //no-op when disabled + return; + } + this.selectedNodes.forEach((node) => { + node.unhighlight(); + if (node.isKEventRef()) { + node.setIsKEventRef(false); + } + }); + + this.selectedNodes.clear(); + this.nodeTransformerRef.clear(); + this.hideContextComponents(); + this.mediaLayerRef.batchDraw(); + } + + public onToggleLock() { + this.updateContextComponents(); + } + public isDragging() { + return this._isDragging; + } + public isNodeSelected(node: MediaNode): boolean { + return this.selectedNodes.has(node); + } + + public dragStart() { + // track the dragging state for mouseevents in nodes + this._isDragging = true; + this.hideContextComponents(); + + // track all nodes initial position when dragstart + this.selectedNodes.forEach((selectedNode) => { + this.initialPositions.set(selectedNode, selectedNode.kNode.position()); + }); + } + + public dragEnd() { + // release the dragging state for mouseevents in nodes + this._isDragging = false; + // track and map the final positions + const finalPositions = new Map(); + this.selectedNodes.forEach((currNode) => { + finalPositions.set(currNode, currNode.kNode.position()); + }); + // dispatch the info for engine to manage un-redo stack + this.eventTarget.dispatchEvent( + new CustomEvent( + SelectionManagerEvents.NODES_TRANSLATIONS, + { + detail: { + nodes: this.selectedNodes, + initialPositions: new Map(this.initialPositions), + finalPositions: finalPositions, + }, + }, + ), + ); + // done, clear data and show menu + this.initialPositions.clear(); + this.updateContextComponents(); + } + + private getKNodeTransformation(kNode: Konva.Node) { + return { + kNodeId: kNode._id, + position: kNode.position(), + size: kNode.size(), + rotation: kNode.rotation(), + scale: { + x: kNode.scaleX(), + y: kNode.scaleY(), + }, + }; + } + public transformStart() { + // track all nodes initial transformation when transformStart + this.selectedNodes.forEach((selectedNode) => { + const transformations = [this.getKNodeTransformation(selectedNode.kNode)]; + if (selectedNode.kNode instanceof Konva.Group) { + const childKNodes = selectedNode.kNode.getChildren(); + childKNodes.forEach((childKNode: Konva.Node) => { + transformations.push(this.getKNodeTransformation(childKNode)); + }); + } + this.initialTransformations.set(selectedNode, transformations); + }); + this.hideContextComponents(); + } + public transformEnd() { + // track and map the final positions + const finalTransformations = new Map(); + this.selectedNodes.forEach((selectedNode) => { + const transformations = [this.getKNodeTransformation(selectedNode.kNode)]; + if (selectedNode.kNode instanceof Konva.Group) { + const childKNodes = selectedNode.kNode.getChildren(); + childKNodes.forEach((childKNode: Konva.Node) => { + transformations.push(this.getKNodeTransformation(childKNode)); + }); + } + finalTransformations.set(selectedNode, transformations); + }); + // dispatch the info for engine to manage un-redo stack + this.eventTarget.dispatchEvent( + new CustomEvent( + SelectionManagerEvents.NODES_TRANSFORMATION, + { + detail: { + nodes: this.selectedNodes, + initialTransformations: new Map(this.initialTransformations), + finalTransformations: finalTransformations, + }, + }, + ), + ); + } + + public updateNodeTransformer() { + const lockFlag = this.selectedNodes.values().next().value?.isLocked(); + if (lockFlag) { + this.nodeTransformerRef.disable(); + } else { + this.nodeTransformerRef.enable(); + } + this.nodeTransformerRef.addNodes({ selectedNodes: this.selectedNodes }); + } + + public hideContextComponents() { + uiAccess.toolbarNode.hide(); + uiAccess.loadingBar.hide(); + } + + public showContextComponents(at?: { x: number, y: number }) { + const showOrUpdate = uiAccess.toolbarNode.isShowing() + ? uiAccess.toolbarNode.update + : uiAccess.toolbarNode.show; + + // If a position is received, set it + // Otherwise use the previous position + const position = at ? at : uiAccess.toolbarNode.signal.value.position; + + const node = this.selectedNodes.values().next().value; + if (node === undefined) { + this.hideContextComponents(); + return; // no node + } + if (this.selectedNodes.size === 1) { + if (node instanceof ImageNode) { + showOrUpdate({ + knodeIds: [node.kNode.id()], + locked: node.isLocked(), + buttonStates: getImageNodeButtonStates({ locked: node.isLocked() }), + position: position + }); + } + if (node instanceof PreviewCopyNode) { + showOrUpdate({ + knodeIds: [node.kNode.id()], + locked: node.isLocked(), + buttonStates: getPreviewCopyNodeButtonStates({ locked: node.isLocked() }), + position: position + }); + } + else if (node instanceof ShapeNode) { + showOrUpdate({ + knodeIds: [node.kNode.id()], + locked: node.isLocked(), + buttonStates: getShapeNodeButtonStates({ locked: node.isLocked() }), + color: node.color, + position: position + }); + } + else if (node instanceof TextNode) { + showOrUpdate({ + knodeIds: [node.kNode.id()], + locked: node.isLocked(), + buttonStates: getTextNodeButtonStates({ locked: node.isLocked() }), + color: node.getNodeData({ x: 0, y: 0 })?.textNodeData?.color ?? "#000000", + position: position + }); + } + else if (node instanceof VideoNode) { + showOrUpdate({ + locked: node.isLocked(), + buttonStates: getVideoNodeButtonStates({ locked: node.isLocked() }), + position: position + }); + } + + // show loading bar is the node is noding + if (node instanceof NetworkedNode) { + if (!node.didFinishLoading && !uiAccess.loadingBar.isShowing()) { + // console.log("show loading bar"); + uiAccess.loadingBar.show(); + } + } + } else { + if (node) { + showOrUpdate({ + locked: node.isLocked(), + buttonStates: getMultiSelectButtonStates({ locked: node.isLocked() }), + position: position + }); + } + } + } + + public updateContextComponents() { + // use first node as reference + const node = this.selectedNodes.values().next().value; + if (node === undefined) { + this.hideContextComponents(); + return; // no nodes + } + + // console.log("SelectionManager > updateContextComponents for node:", node); + const coord = calculateContextualsPosition( + this.nodeTransformerRef.getKonvaNode(), + ); + + if (node instanceof VideoNode) { + if (node.isSegmentationMode && !uiAccess.toolbarNode.isLockDisabled()) { + uiAccess.toolbarNode.disableLock(); + } else { + uiAccess.toolbarNode.enableLock(); + } + } + if (node.isLocked() !== uiAccess.toolbarNode.isLocked()) { + // console.log("setting lock"); + uiAccess.toolbarNode.setLocked(node.isLocked()); + } + console.debug("Setting context position to:", coord) + uiAccess.toolbarNode.setPosition({ + x: coord.x, + y: coord.y, + }); + if (node instanceof NetworkedNode) { + uiAccess.loadingBar.update({ + position: coord, + progress: node.progress(), + status: node.isError() + ? LoadingBarStatus.ERROR + : LoadingBarStatus.LOADING, + message: node.progressMessage(), + }); + uiEvents.toolbarNode.retry.onClick((e) => { + if (e) { + console.log("CONTEXTUAL LOADING BAR RETRY ONCLICK", e, node); + node.retry(); + } + }); + if (node.didFinishLoading) { + // console.log("node finished loading", node); + uiAccess.loadingBar.hide(); + } + } + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/SelectorSquare.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/SelectorSquare.ts new file mode 100644 index 0000000000..3752e56681 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/SelectorSquare.ts @@ -0,0 +1,176 @@ +import Konva from "konva"; +import { NodesManager } from "./NodesManager"; +import { SelectionManager } from "./SelectionManager"; +import { MediaNode } from "../types"; + +interface SqaureCoordinates { + x1: number; + y1: number; + x2: number; + y2: number; +} +export class SelectorSquare { + private kSquare: Konva.Rect; + private selecting: boolean = false; + private sqaureCoordinates: SqaureCoordinates = { + x1: 0, + x2: 0, + y1: 0, + y2: 0, + }; + + // engine class references + private captureCanvasRef: Konva.Rect; + private mediaLayerRef: Konva.Layer; + private nodesManagerRef: NodesManager; + private selectionManagerRef: SelectionManager; + private stageRef: Konva.Stage; + + constructor({ + captureCanvasRef, + mediaLayerRef, + nodesManagerRef, + selectionManagerRef, + stageRef, + }: { + captureCanvasRef: Konva.Rect; + mediaLayerRef: Konva.Layer; + nodesManagerRef: NodesManager; + selectionManagerRef: SelectionManager; + stageRef: Konva.Stage; + }) { + this.kSquare = new Konva.Rect({ + fill: "rgba(45, 129, 255, 0.3)", + stroke: "#00a1ff", + strokeWidth: 1, + visible: false, + // disable events to not interrupt with events + listening: false, + }); + this.captureCanvasRef = captureCanvasRef; + this.mediaLayerRef = mediaLayerRef; + this.nodesManagerRef = nodesManagerRef; + this.selectionManagerRef = selectionManagerRef; + this.stageRef = stageRef; + } + public getKonvaNode() { + return this.kSquare; + } + + public disable() { + this.stageRef.off("mousedown touchstart"); + this.stageRef.off("mousemove touchmove"); + this.stageRef.off("mouseup touchend"); + this.stageRef.off("mouseleave"); + } + public enable() { + // Remove any existing event handlers first to prevent duplicates + this.disable(); + + this.stageRef.on("mousedown touchstart", (e) => { + const stagePointerPos = this.stageRef.getPointerPosition(); + if ( + (e.target !== this.stageRef && e.target !== this.captureCanvasRef) || // do nothing if we mousedown on any shape + stagePointerPos === null || // do nothing if pointers not available + e.evt.shiftKey // do nothing so then multiselect is more forgiving in misclicks + ) { + return; + } + + // start handle mousedown + e.evt.preventDefault(); + if (e.target === this.stageRef || e.target === this.captureCanvasRef) { + //moused down on empty space, clear previous selection first + this.selectionManagerRef.clearSelection(); + } + + // this starts drawing the square + this.sqaureCoordinates = { + x1: stagePointerPos.x, + y1: stagePointerPos.y, + x2: stagePointerPos.x, + y2: stagePointerPos.y, + }; + + this.kSquare.size({ + width: 0, + height: 0, + }); + this.selecting = true; + }); + + this.stageRef.on("mousemove touchmove", (e) => { + // do nothing if we didn't start selection + const stagePointerPos = this.stageRef.getPointerPosition(); + if (!this.selecting || stagePointerPos === null) { + return; + } + + // handle drawing square according to mouse move + e.evt.preventDefault(); + this.sqaureCoordinates = { + ...this.sqaureCoordinates, + x2: stagePointerPos.x, + y2: stagePointerPos.y, + }; + const { x1, y1, x2, y2 } = this.sqaureCoordinates; + this.kSquare.moveToTop(); + this.kSquare.setAttrs({ + visible: true, + x: Math.min(x1, x2), + y: Math.min(y1, y2), + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + }); + }); + + this.stageRef.on("mouseup touchend", (e) => { + // do nothing if we didn't start selection + if (!this.selecting) { + return; + } + + this.selecting = false; + if (!this.kSquare.visible()) { + return; + } + e.evt.preventDefault(); + // update visibility + this.kSquare.visible(false); + // Find all the Nodes and feed them into Selectmanager + var shapes = this.mediaLayerRef.getChildren(); + var box = this.kSquare.getClientRect(); + var foundKNodes = shapes.filter((shape) => + Konva.Util.haveIntersection(box, shape.getClientRect()), + ); + if (foundKNodes.length > 0) { + const kNodeIds = foundKNodes.map((kNode) => kNode._id); + const foundNodes = Array.from( + this.nodesManagerRef.getAllNodes(), + ).reduce((accNodes, currNode) => { + if (kNodeIds.includes(currNode.kNode._id) && !currNode.isLocked()) { + accNodes.push(currNode); + } + return accNodes; + }, [] as MediaNode[]); + console.log("selector square found", foundNodes); + this.selectionManagerRef.selectNodes(foundNodes); + } + }); + + this.stageRef.on("mouseleave", (e) => { + if (!this.selecting) { + return; + } + + this.selecting = false; + if (!this.kSquare.visible()) { + return; + } + e.evt.preventDefault(); + // update visibility + this.kSquare.visible(false); + // and then do nothing, this is considered a cancel + }); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/ToolbarNodeUtilities.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/ToolbarNodeUtilities.ts new file mode 100644 index 0000000000..ba06829c09 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/ToolbarNodeUtilities.ts @@ -0,0 +1,236 @@ +import Konva from "konva"; +import { ToolbarNodeButtonNames } from "~/components/features/ToolbarNode"; +import { ContextualToolbarProps } from "~/signals/uiAccess/toolbarNode"; +export function calculateContextualsPosition(kNode: Konva.Transformer) { + const w = kNode.getSize().width * kNode.scaleX(); + const h = kNode.getSize().height * kNode.scaleY(); + const x0 = kNode.getPosition().x; + const y0 = kNode.getPosition().y; + + const d = kNode.getAbsoluteRotation(); + const r = d >= 0 ? (d * Math.PI) / 180 : ((360 + d) * Math.PI) / 180; + + let px: number, py: number; + if (r < Math.PI / 2) { + //top right quadrants + px = x0 + (h * Math.sin(r) + w * Math.cos(r)) / 2 - h * Math.sin(r); + py = y0 + h * Math.cos(r) + w * Math.cos(Math.PI / 2 - r); + } else if (r < Math.PI) { + //lower right quadrants + px = x0 - (h * Math.sin(r) - w * Math.cos(r)) / 2; + py = y0 + w * Math.cos(Math.PI / 2 - r); + } else if (r < (Math.PI * 3) / 2) { + //lower left quadrants + px = x0 + h * Math.cos(r) - (h * Math.cos(r) + w * Math.sin(r)) / 2; + py = y0; + } else { + //top left quadrants + px = x0 + (-h * Math.sin(r) + w * Math.cos(r)) / 2; + py = y0 + h * Math.cos(r); + } + return { x: px, y: py }; +} + +export function getImageNodeButtonStates( + props: { locked?: boolean | "unknown" } = {}, +) { + const ButtonNames = ToolbarNodeButtonNames; + return Object.values(ButtonNames).reduce( + (buttonStates, buttonName) => { + switch (buttonName) { + case ButtonNames.AI_STYLIZE: + case ButtonNames.SEGMENTATION: + case ButtonNames.DOWNLOAD: + case ButtonNames.CHROMA: + case ButtonNames.COLOR: + case ButtonNames.TRANSFORM: + // hidden buttons + buttonStates[buttonName] = { + disabled: true, + hidden: true, + active: false, + }; + return buttonStates; + + default: + // all other buttons + buttonStates[buttonName] = { + disabled: false, + hidden: false, + active: false, + }; + return buttonStates; + } + }, + {} as ContextualToolbarProps["buttonStates"], + ); +} + +export function getPreviewCopyNodeButtonStates( + props: { locked?: boolean | "unknown" } = {}, +) { + const ButtonNames = ToolbarNodeButtonNames; + return Object.values(ButtonNames).reduce( + (buttonStates, buttonName) => { + switch (buttonName) { + case ButtonNames.AI_STYLIZE: + case ButtonNames.SEGMENTATION: + case ButtonNames.DOWNLOAD: + case ButtonNames.CHROMA: + case ButtonNames.COLOR: + case ButtonNames.TRANSFORM: + // hidden buttons + buttonStates[buttonName] = { + disabled: true, + hidden: true, + active: false, + }; + return buttonStates; + + default: + // all other buttons + buttonStates[buttonName] = { + disabled: false, + hidden: false, + active: false, + }; + return buttonStates; + } + }, + {} as ContextualToolbarProps["buttonStates"], + ); +} +export function getTextNodeButtonStates( + props: { locked?: boolean | "unknown" } = {}, +) { + const ButtonNames = ToolbarNodeButtonNames; + return Object.values(ButtonNames).reduce( + (buttonStates, buttonName) => { + switch (buttonName) { + case ButtonNames.AI_STYLIZE: + case ButtonNames.SEGMENTATION: + case ButtonNames.DOWNLOAD: + case ButtonNames.CHROMA: + case ButtonNames.REMOVE_BACKGROUND: + case ButtonNames.TRANSFORM: + // hidden buttons + buttonStates[buttonName] = { + disabled: true, + hidden: true, + active: false, + }; + return buttonStates; + + default: + // all other buttons + buttonStates[buttonName] = { + disabled: false, + hidden: false, + active: false, + }; + return buttonStates; + } + }, + {} as ContextualToolbarProps["buttonStates"], + ); +} +export function getVideoNodeButtonStates( + props: { locked?: boolean | "unknown" } = {}, +) { + const ButtonNames = ToolbarNodeButtonNames; + return Object.values(ButtonNames).reduce( + (buttonStates, buttonName) => { + switch (buttonName) { + case ButtonNames.AI_STYLIZE: + case ButtonNames.COLOR: + case ButtonNames.REMOVE_BACKGROUND: + case ButtonNames.TRANSFORM: + // soon to come feature is disabled + buttonStates[buttonName] = { + disabled: true, + hidden: true, + active: false, + }; + return buttonStates; + default: + // all other buttons + buttonStates[buttonName] = { + disabled: false, + hidden: false, + active: false, + }; + return buttonStates; + } + }, + {} as ContextualToolbarProps["buttonStates"], + ); +} + +export function getShapeNodeButtonStates( + props: { locked?: boolean | "unknown" } = {}, +) { + const ButtonNames = ToolbarNodeButtonNames; + return Object.values(ButtonNames).reduce( + (buttonStates, buttonName) => { + switch (buttonName) { + case ButtonNames.AI_STYLIZE: + case ButtonNames.SEGMENTATION: + case ButtonNames.DOWNLOAD: + case ButtonNames.CHROMA: + case ButtonNames.REMOVE_BACKGROUND: + case ButtonNames.TRANSFORM: + // hidden buttons + buttonStates[buttonName] = { + disabled: true, + hidden: true, + active: false, + }; + return buttonStates; + default: + // all other buttons + buttonStates[buttonName] = { + disabled: false, + hidden: false, + active: false, + }; + return buttonStates; + } + }, + {} as ContextualToolbarProps["buttonStates"], + ); +} + +export function getMultiSelectButtonStates( + props: { locked?: boolean | "unknown" } = {}, +) { + const ButtonNames = ToolbarNodeButtonNames; + return Object.values(ButtonNames).reduce( + (buttonStates, buttonName) => { + switch (buttonName) { + case ButtonNames.AI_STYLIZE: + case ButtonNames.SEGMENTATION: + case ButtonNames.DOWNLOAD: + case ButtonNames.CHROMA: + case ButtonNames.COLOR: + case ButtonNames.REMOVE_BACKGROUND: + case ButtonNames.TRANSFORM: + // hidden buttons + buttonStates[buttonName] = { + disabled: true, + hidden: true, + active: false, + }; + return buttonStates; + default: + // all other buttons + buttonStates[buttonName] = { + disabled: false, + hidden: false, + active: false, + }; + return buttonStates; + } + }, + {} as ContextualToolbarProps["buttonStates"], + ); +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/index.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/index.ts new file mode 100644 index 0000000000..a9bf7359ac --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/NodesManagers/index.ts @@ -0,0 +1,10 @@ +export { NodesManager } from "./NodesManager"; +export { NodeIsolator } from "./NodeIsolator"; +export { NodeTransformer } from "./NodeTransformer"; +export { SelectionManager, SelectionManagerEvents } from "./SelectionManager"; +export { SelectorSquare } from "./SelectorSquare"; + +export type { + NodesTranslationEventDetails, + NodeTransformationEventDetails, +} from "./SelectionManager"; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/OffScreenSceneCanvas.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/OffScreenSceneCanvas.ts new file mode 100644 index 0000000000..ce59dc19fb --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/OffScreenSceneCanvas.ts @@ -0,0 +1,59 @@ +//@ts-ignore +// TODO: have to export and modify the code +import { ICanvasConfig } from "konva/lib/Canvas"; + +import { Canvas } from "konva/lib/Canvas"; +import { OffScreenSceneContext } from "./OffScreenSceneContext"; + +export class OffScreenSceneCanvas extends Canvas { + constructor( + config: ICanvasConfig = { width: 0, height: 0, willReadFrequently: false }, + ) { + super(config); // creates canvas then you have to replace it + //@ts-ignore + // replace here also kills the css but thats ok + this._canvas = new OffscreenCanvas(config.width, config.height); + + this.context = new OffScreenSceneContext(this, { + willReadFrequently: config.willReadFrequently, + }); + console.log("Created Offscreen Scene Canvas"); + this.setSize(config.width, config.height); + } + + setPixelRatio(pixelRatio: number) { + console.log("overriden setPixelRatio"); + var previousRatio = this.pixelRatio; + this.pixelRatio = pixelRatio; + this.setSize( + this.getWidth() / previousRatio, + this.getHeight() / previousRatio, + ); + } + public override setWidth(width: number) { + // take into account pixel ratio + // console.log("overriden setWidth"); + this.width = this._canvas.width = width * this.pixelRatio; + var pixelRatio = this.pixelRatio, + _context = this.getContext()._context; + _context.scale(pixelRatio, pixelRatio); + } + public override setHeight(height: number) { + // take into account pixel ratio + // console.log("overriden setHeight"); + this.height = this._canvas.height = height * this.pixelRatio; + var pixelRatio = this.pixelRatio, + _context = this.getContext()._context; + _context.scale(pixelRatio, pixelRatio); + } + getWidth() { + return this.width; + } + getHeight() { + return this.height; + } + setSize(width: number | undefined, height: number | undefined) { + this.setWidth(width || 0); + this.setHeight(height || 0); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/OffScreenSceneContext.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/OffScreenSceneContext.ts new file mode 100644 index 0000000000..b01064dacd --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/OffScreenSceneContext.ts @@ -0,0 +1,11 @@ +import { Context } from "konva/lib/Context"; +import { Canvas } from "konva/lib/Canvas"; + +export class OffScreenSceneContext extends Context { + constructor(canvas: Canvas, { willReadFrequently = false } = {}) { + super(canvas); + this._context = canvas._canvas.getContext("2d", { + willReadFrequently, + }) as CanvasRenderingContext2D; + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/RealTimeDrawEngine.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/RealTimeDrawEngine.ts new file mode 100644 index 0000000000..16b613ed80 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/RealTimeDrawEngine.ts @@ -0,0 +1,892 @@ +import Konva from "konva"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; + +import { FileUtilities } from "../../utilities/FileUtilities"; +import { ImageNode, TextNode, ShapeNode, PreviewCopyNode, PaintNode } from "../Nodes"; +import { MediaNode } from "../types"; +import { RenderTask } from "./RenderTask"; + + +import { + isLoadingVisible, + loadingProgress, +} from "../../signals/uiEvents/loadingIndicator"; + +import { EncodeImageBitmapToBase64 } from "../../utilities/EncodeImageBitmapToBase64"; +import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap"; + +interface ServerSettings { + model_path: string; + lora_path?: string; +} + +export class RealTimeDrawEngine { + + private imageNodes: (ImageNode | TextNode | ShapeNode | PaintNode)[]; + + private offScreenCanvas: OffscreenCanvas; + private outputBitmap: ImageBitmap | undefined; + + // private frames: ImageBitmap[]; + // capturing composite within window + + private mediaLayerRef: Konva.Layer; + private drawingsLayer: Konva.Layer; // New Layer for Drawings + + private height: number; + private width: number; + private positionX: number; + private positionY: number; + private positionPreviewX: number; + private positionPreviewY: number; + + private port: MessagePort | undefined; + public captureCanvas: Konva.Rect; + public backgroundRasterRect: Konva.Image; + + + public fps: number = 24; + + public currentPrompt: string; + public currentStrength: number; + + public lastRenderedBitmap: ImageBitmap | undefined; + + // Paint Color + // paint Brush Size + // has to exit out of paint mode when shape or image are used. + public paintColor: string = "#000000"; + private brushSize: number = 5; // Default brush size + + private onDrawCallback?: ( + canvas: HTMLCanvasElement, + lineBounds: { + width: number; + height: number; + x: number; + y: number; + }, + ) => void; + private offscreenRenderDiv: HTMLDivElement; + + private client: WebSocketClient | null = null; + private isConnected: boolean = false; + + public backgroundColor: string = "#d2d2d2"; // off white + constructor({ + width, + height, + mediaLayerRef, + offScreenCanvas, + onDraw, + onPreviewCopy, // New Parameter + }: { + width: number; + height: number; + mediaLayerRef: Konva.Layer; + offScreenCanvas: OffscreenCanvas; + onDraw?: ( + canvas: HTMLCanvasElement, + lineBounds: { + width: number; + height: number; + x: number; + y: number; + }, + ) => void; + onPreviewCopy?: (previewCopy: Konva.Image) => void; // New Parameter + }) { + + this.imageNodes = []; + this.onDrawCallback = onDraw; + + // TODO: Make this dynamic and update this on change of canvas. + this.width = width * 1.5; + this.height = height * 1; + + this.positionX = window.innerWidth - this.width; + this.positionY = window.innerHeight - this.height; + + + this.offScreenCanvas = offScreenCanvas; + this.offScreenCanvas.width = this.width; + this.offScreenCanvas.height = this.height; + + // this is the whole canvas + this.mediaLayerRef = mediaLayerRef; + + // Create a separate layer for drawings + this.drawingsLayer = new Konva.Layer({ + clearBeforeDraw: true, // Ensures transparent background + }); + + this.mediaLayerRef.getStage()?.add(this.drawingsLayer); // to od pass in stage + + // Set background layer to red and media layer to green for visibility + + this.port = undefined; + + this.fps = 24; + + this.currentPrompt = ""; + this.currentStrength = 1; + + // This is captures a subset of the medialayer ref + this.captureCanvas = new Konva.Rect({ + name: "CaptureCanvas", + x: this.positionX, + y: this.positionY, + width: this.width, + height: this.height, + stroke: "blue", + strokeWidth: 1, + cornerRadius: 10, + draggable: false, + }); + + this.offscreenRenderDiv = document.createElement("div"); + + this.mediaLayerRef.add(this.captureCanvas); + + this.captureCanvas.setZIndex(0); + + + this.listenToServerEvents(); + } + + public async listenToServerEvents() { + listen("notification", (event) => { + console.log(event); + // Handle model download events + const payload = event.payload as any; + + // Model download started + if (payload.model_download_started) { + const modelInfo = payload.model_download_started; + console.log( + `Model download started: ${modelInfo.model_name} (${modelInfo.model_type})`, + ); + + // Set up loading indicator + isLoadingVisible.value = true; + loadingProgress.value = 0; + + // Create fake progress updates + const downloadTimer = setInterval(() => { + loadingProgress.value += 2; + // Cap at 95% until we get the completed event + if (loadingProgress.value >= 95) { + loadingProgress.value = 95; + clearInterval(downloadTimer); + } + }, 500); + } + + // Model download completed + if (payload.model_download_complete) { + const modelInfo = payload.model_download_complete; + console.log( + `Model download completed: ${modelInfo.model_name} (${modelInfo.model_type})`, + ); + + // Complete the loading progress + loadingProgress.value = 100; + + // Hide loading indicator after a short delay + setTimeout(() => { + isLoadingVisible.value = false; + }, 1000); + } + }); + } + + private isEnabled: boolean = false; + private cleanupFunction: (() => void) | null = null; + + // this starts the python server + public async startServer() { + try { + this.client = new WebSocketClient("ws://localhost:8765"); + + // Set up message handlers + this.client.onProgress((progress) => { + console.log( + `Progress: ${progress.message} (${progress.progress * 100}%)`, + ); + if (progress.error) { + console.error("Error:", progress.error); + } + }); + + // Wait for connection to be established + await new Promise((resolve, reject) => { + if (!this.client) return reject("Client not initialized"); + + this.client.ws.onopen = () => { + this.isConnected = true; + resolve(); + }; + this.client.ws.onerror = (error) => reject(error); + }); + + // Load initial model + await this.loadModel({ + model_path: "C:/Users/Tensor/Downloads/animagineXL40_v4Opt.safetensors", + lora_path: "C:/Users/Tensor/Downloads/anyu_all.safetensors", + }); + } catch (error) { + console.error("Failed to start server:", error); + this.isConnected = false; + } + } + + private async loadModel(settings: ServerSettings) { + if (!this.client || !this.isConnected) { + throw new Error("Server not connected"); + } + await this.client.loadModel(settings); + } + + private updateCursor(stage: Konva.Stage) { + // Create cursor canvas + const cursorCanvas = document.createElement("canvas"); + const size = this.brushSize * 5; // Match the brush size used in drawing + cursorCanvas.width = size * 2; // Double size for padding + cursorCanvas.height = size * 2; + + const ctx = cursorCanvas.getContext("2d"); + if (!ctx) return; + + // Draw the outer circle with a light stroke + ctx.beginPath(); + ctx.arc(size, size, size / 2, 0, Math.PI * 2); + ctx.strokeStyle = "white"; + ctx.lineWidth = 3; + ctx.stroke(); + + // Draw the inner circle with a dark stroke + ctx.beginPath(); + ctx.arc(size, size, size / 2 - 1, 0, Math.PI * 2); + ctx.strokeStyle = "black"; + ctx.lineWidth = 1; + ctx.stroke(); + + // Convert to data URL + const cursorUrl = cursorCanvas.toDataURL(); + + // Apply custom cursor + stage.container().style.cursor = `url(${cursorUrl}) ${size} ${size}, auto`; + } + + public paintMode() { + let isDrawing = false; + let currentLine: Konva.Line | null = null; + + const stage = this.mediaLayerRef.getStage(); + if (!stage) return; + + // Initialize cursor + this.updateCursor(stage); + + const startDrawing = (pos: { x: number; y: number }) => { + if (!this.isEnabled) return; + const stage = this.mediaLayerRef.getStage(); + if (!stage) return; + + // Convert pointer position to relative position within capture canvas + const captureBox = this.captureCanvas.getClientRect(); + const relativeX = pos.x - captureBox.x; + const relativeY = pos.y - captureBox.y; + + currentLine = new Konva.Line({ + points: [relativeX, relativeY], + stroke: this.paintColor, + strokeWidth: this.brushSize * 5, + lineCap: "round", + lineJoin: "round", + x: this.captureCanvas.x(), + y: this.captureCanvas.y(), + draggable: false, + }); + this.drawingsLayer.moveToTop(); + this.drawingsLayer.add(currentLine); // Add to drawingsLayer + isDrawing = true; + }; + + const draw = (pos: { x: number; y: number }) => { + if (!this.isEnabled) return; + if (!isDrawing || !currentLine) return; + + // Convert pointer position to relative position + const captureBox = this.captureCanvas.getClientRect(); + const relativeX = pos.x - captureBox.x; + const relativeY = pos.y - captureBox.y; + + const newPoints = currentLine.points().concat([relativeX, relativeY]); + currentLine.points(newPoints); + this.drawingsLayer.batchDraw(); + }; + + const stopDrawing = () => { + if (!this.isEnabled) return; + if (!isDrawing || !currentLine) return; + + // Store current line before resetting state + const lineToConvert = currentLine; + + // Reset drawing state immediately so we can start a new stroke + isDrawing = false; + currentLine = null; + + // Create a temporary layer to render the line + const tempLayer = new Konva.Layer({ + clearBeforeDraw: true, + }); + this.mediaLayerRef.getStage()?.add(tempLayer); + tempLayer.add(lineToConvert); + + // Get the line canvas with transparent background + // Get the bounding box of the line + const lineBounds = lineToConvert.getClientRect(); + + // Create canvas with just enough size to contain the line + const lineCanvas = tempLayer.toCanvas({ + x: lineBounds.x, + y: lineBounds.y, + width: lineBounds.width, + height: lineBounds.height, + pixelRatio: 1, + }); + + if (this.onDrawCallback) { + this.onDrawCallback(lineCanvas, lineBounds); + } + + // Clean up + lineToConvert.destroy(); + tempLayer.destroy(); + + this.drawingsLayer.batchDraw(); + }; + + // Check if point is within capture canvas bounds + const isWithinCaptureCanvas = (pos: { x: number; y: number }) => { + const captureBox = this.captureCanvas.getClientRect(); + return ( + pos.x >= captureBox.x && + pos.x <= captureBox.x + captureBox.width && + pos.y >= captureBox.y && + pos.y <= captureBox.y + captureBox.height + ); + }; + + // Add event listeners + stage.on("mousedown touchstart", (e) => { + const pos = stage.getPointerPosition(); + if (pos && isWithinCaptureCanvas(pos)) { + startDrawing(pos); + } + }); + + stage.on("mousemove touchmove", (e) => { + const pos = stage.getPointerPosition(); + if (pos && isWithinCaptureCanvas(pos)) { + draw(pos); + } + }); + + stage.on("mouseup touchend", async () => { + stopDrawing(); + await this.render(); + }); + + // Store cleanup function + this.cleanupFunction = () => { + stage.off("mousedown touchstart"); + stage.off("mousemove touchmove"); + stage.off("mouseup touchend"); + }; + } + + public enablePaintMode() { + this.isEnabled = true; + this.disableDragging(); + if (!this.cleanupFunction) { + this.paintMode(); + } + // Update cursor when enabling paint mode + const stage = this.mediaLayerRef.getStage(); + if (stage) { + this.updateCursor(stage); + } + } + + public enableDragging() { + // Enable dragging for all nodes in media layer + this.imageNodes?.forEach((node) => { + console.log("Image Nodes Enable"); + console.log(node); + node.kNode.draggable(true); + node.kNode.listening(true); + }); + + this.mediaLayerRef.batchDraw(); + } + + public disableDragging() { + // Disable dragging for all nodes in media layer + this.imageNodes?.forEach((node) => { + node.kNode.draggable(false); + node.kNode.listening(false); + }); + this.mediaLayerRef.batchDraw(); + } + + public disablePaintMode() { + this.isEnabled = false; + this.enableDragging(); + if (this.cleanupFunction) { + this.cleanupFunction(); + this.cleanupFunction = null; + } + // Reset cursor when disabling paint mode + const stage = this.mediaLayerRef.getStage(); + if (stage) { + stage.container().style.cursor = "default"; + } + } + + public findImageNodeById( + id: string, + ): ImageNode | TextNode | ShapeNode | PaintNode | undefined { + return this.imageNodes.find((node) => { + if (node.kNode) { + return node.kNode.id() === id; + } + return false; + }); + } + async updateCaptureCanvas( + width: number | undefined, + height: number | undefined, + ) { + console.log("updateCaptureCanvas"); + if (!this.captureCanvas) { + return; + } + const scaleFactor = 0.9; + // Store old values for calculating delta + const oldPositionX = this.positionX; + const oldPositionY = this.positionY; + + const oldWidth = this.width; + const oldHeight = this.height; + + // Original aspect ratio + const originalAspectRatio = oldWidth / oldHeight; + + // Calculate new dimensions based on window size + let newWidth: number; + let newHeight: number; + + if (width === undefined) { + newWidth = window.innerWidth * scaleFactor; + } else { + newWidth = width * scaleFactor; + } + + if (height === undefined) { + newHeight = window.innerHeight * scaleFactor; + } else { + newHeight = height * scaleFactor; + } + + // Preserve aspect ratio + const newAspectRatio = newWidth / newHeight; + + if (newAspectRatio > originalAspectRatio) { + // Width is constraining factor + newWidth = newHeight * originalAspectRatio; + } else { + // Height is constraining factor + newHeight = newWidth / originalAspectRatio; + } + + this.width = newWidth; + this.height = newHeight; + + console.log("New dimensions:", this.width, this.height); + + // Calculate new position to center the canvas in the window + this.positionX = (window.innerWidth - this.width) / 2; + this.positionY = (window.innerHeight - this.height) / 2; + + console.log("New centered position:", this.positionX, this.positionY); + + // Update the canvas position and size + this.captureCanvas.setPosition({ + x: this.positionX, + y: this.positionY, + }); + this.captureCanvas.size({ width: this.width, height: this.height }); + + // Calculate scale factors to maintain proportions + const scaleX = this.width / oldWidth; + const scaleY = this.height / oldHeight; + + // Use a uniform scale factor to preserve aspect ratio + const uniformScale = Math.min(scaleX, scaleY); + + // Update all children except the capture canvas + var children = this.mediaLayerRef.getChildren(); + for (let i = 0; i < children.length; i++) { + let node = children[i]; + + // skip the capture canvas and preview canvas update. + if (node.name() === "CaptureCanvas") { + continue; + } + + const pos = node.getPosition(); + const oldScale = node.scale(); + + // Apply both position shift and scaling to maintain relative positioning + node.setPosition({ + x: (pos.x - oldPositionX) * uniformScale + this.positionX, + y: (pos.y - oldPositionY) * uniformScale + this.positionY, + }); + + // Update scale proportionally if the node has a scale property + if ( + oldScale && + typeof oldScale.x === "number" && + typeof oldScale.y === "number" + ) { + node.scale({ + x: oldScale.x * uniformScale, + y: oldScale.y * uniformScale, + }); + } + } + + this.mediaLayerRef.batchDraw(); + } + + debug() { + // DEBUG ONLY + const rectangle = new Konva.Rect({ + x: this.positionX, + y: this.positionY, + width: 100, + height: 100, + fill: "green", + stroke: "black", + strokeWidth: 1, + draggable: false, + }); + this.mediaLayerRef.add(rectangle); + } + + async sendCanvasPayload(renderTask: RenderTask) { + if (!this.port) { + return console.log("Undefined Worker"); + } + this.port.postMessage(renderTask); + } + + public placeDebugRect( + x: number, + y: number, + width: number, + height: number, + layer: Konva.Layer, + ): void { + const rect = new Konva.Rect({ + x: x, + y: y, + width: width, // Default width + height: height, // Default height + fill: "green", + draggable: false, + }); + + layer.add(rect); + layer.draw(); + } + + // This function uses a portion of the video layer to capture just the capture canvas. + // capture everything after seeking each video node. + renderPortionOfLayer( + layer: Konva.Layer, + x: number, + y: number, + width: number, + height: number, + ): HTMLCanvasElement { + const canvas = layer.toCanvas({ + x: x, + y: y, + width: width, + height: height, + }); + return canvas; + } + + public isProcessing = false; + private handleNodeDragEnd = async () => { + // Clean up any existing state + + await this.render(); + }; + + public async addNodes(node: MediaNode) { + if ( + node instanceof ImageNode || + node instanceof TextNode || + node instanceof ShapeNode || + node instanceof PaintNode || + node instanceof PreviewCopyNode + ) { + console.debug("Adding node:", node); + this.imageNodes.push(node); + console.log(this.imageNodes); + } + + // ensure the layer doesn't move if added while painting. + if (this.isEnabled) { + this.disableDragging(); + } + + await this.render(); + } + + public removeNodes(node: MediaNode) { + if (node instanceof VideoNode) { + const index = this.videoNodes.indexOf(node); + if (index > -1) { + node.kNode.off("dragend", this.handleNodeDragEnd); + this.videoNodes.splice(index, 1); + } + } else if ( + node instanceof ImageNode || + node instanceof TextNode || + node instanceof ShapeNode || + node instanceof PaintNode || + node instanceof PreviewCopyNode + ) { + const index = this.imageNodes.indexOf(node); + if (index > -1) { + node.kNode.off("dragend", this.handleNodeDragEnd); + this.imageNodes.splice(index, 1); + } + } + } + + private cloneStageForRender( + stage: Konva.Stage, + layerOfInterest: Konva.Layer, + ): Konva.Stage { + const stageClone = new Konva.Stage({ + width: stage.width(), + height: stage.height(), + container: this.offscreenRenderDiv, + }); + + const renderLayer = new Konva.Layer(); + + // Clone all the nodes then reset them to render right + layerOfInterest.getChildren().forEach((node) => { + const dupNode = node.clone(); + dupNode.strokeWidth(0); + renderLayer.add(dupNode); + }); + + stageClone.add(renderLayer); + + return stageClone; + } + + // private async renderFrame(config: { + // layerOfInterest: Konva.Layer; // layer where the element that you want to clip lives. + // // XY and height of a captureCanvas ( region of interest ) + // x?: number; // x position of the region of interest + // y?: number; // y position of the region of interest + // width?: number; // size of the region of interest + // height?: number; // size of the region of interest + // pixelRatio?: number; // higher means higher quality + // mimeType?: string; // image/jpeg or image/png + // quality?: number; // 1.0 is the best. + // test: boolean; // true == blob else Image Bitmap + // }): Promise { + // try { + // const box = config.layerOfInterest.getClientRect(); + // const stage = config.layerOfInterest.getStage(); + + // const x = config.x !== undefined ? config.x : Math.floor(box.x); + // const y = config.y !== undefined ? config.y : Math.floor(box.y); + // const pixelRatio = config.pixelRatio || 1; + + // // Clone the required layer from the stage + // // Set the right details (like removing highlight stroke) + // // Then render the cloned stage to a bitmap + // const stageClone = this.cloneStageForRender( + // stage, + // config.layerOfInterest, + // ); + // const stageBlob = (await stageClone.toBlob({ + // x: x, + // y: y, + // width: config.width || Math.ceil(box.width), + // height: config.height || Math.ceil(box.height), + // pixelRatio: pixelRatio, + // })) as Blob; + + // // if config.test is true, the result is downloaded to the local files + // // config.test = true; + // if (config.test) { + // await FileUtilities.blobToFileJpeg(stageBlob, "1"); + // } + + // const result = await createImageBitmap(stageBlob); + // return result; + // } catch (error) { + // throw error; + // } + // } + + private async imageBitmapToBase64(imageBitmap: ImageBitmap): Promise { + // Create a temporary canvas + const canvas = document.createElement("canvas"); + canvas.width = imageBitmap.width; + canvas.height = imageBitmap.height; + + // Draw the ImageBitmap onto the canvas + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Failed to get 2D context"); + + ctx.drawImage(imageBitmap, 0, 0); + + // Convert to base64 + const base64String = canvas.toDataURL("image/png"); + + // Clean up + canvas.remove(); + + // Remove the data:image/png;base64, prefix if you want just the base64 string + return base64String.split(",")[1]; + } + + public async saveOutput() { + if (!this.outputBitmap) { + console.error("No output bitmap available to save"); + return; + } + + const base64Bitmap = await EncodeImageBitmapToBase64(this.outputBitmap); + + const saveResponse = await invoke("save_image", { + image: base64Bitmap, + }); + } + + public async generateImage() { + if (!this.lastRenderedBitmap) { + console.error("No rendered bitmap available to generate from"); + return; + } + + const base64Bitmap = await this.imageBitmapToBase64( + this.lastRenderedBitmap, + ); + + const generateResponse = await invoke("image_generation_command", { + image: base64Bitmap, + prompt: this.currentPrompt, + }); + } + + public async render() { + // only pick nodes that intersect wi th the canvas on screen bounds to freeze. + if (this.isProcessing) { + console.log("isProcessing Returning"); + return; + } + console.log("Calling Render"); + this.isProcessing = true; + + this.mediaLayerRef.draw(); + // Output all nodes in mediaLayerRef + const nodes = this.mediaLayerRef.getChildren(); + console.log("All nodes in mediaLayer:", nodes); + console.log( + `context: x:${this.positionX} y:${this.positionY} ${this.width} x ${this.height}`, + ); + + const bitmap = (await this.renderFrame({ + layerOfInterest: this.mediaLayerRef, + x: this.captureCanvas.x(), + y: this.captureCanvas.y(), + width: this.width, + height: this.height, + mimeType: "image/jpeg", + pixelRatio: 1, + quality: 1.0, + test: false, + })) as ImageBitmap; + + this.lastRenderedBitmap = bitmap; + + setCanvasRenderBitmap(bitmap); + + try { + } catch (error) { + console.error("Error during image processing:", error); + } finally { + this.isProcessing = false; + } + } + + // Add getter/setter for brush size + public set paintBrushSize(size: number) { + this.brushSize = size * 7.5; + const stage = this.mediaLayerRef.getStage(); + if (stage && this.isEnabled) { + this.updateCursor(stage); + } + } + + public get paintBrushSize(): number { + return this.brushSize; + } + + // Add method to check connection status + public isServerConnected(): boolean { + return this.isConnected && this.client !== null; + } + + // Add method to reconnect if needed + public async reconnect() { + if (this.client) { + this.client.ws.close(); + } + await this.startServer(); + } + + // Add method to create or update background + public updateBackground(color: string) { + this.captureCanvas.fill(color); + + const captureCanvasImage = this.captureCanvas.toDataURL(); + const imageSource = new Image(); + imageSource.src = captureCanvasImage; + + imageSource.onload = () => { + this.backgroundRasterRect.fill(color); + this.backgroundRasterRect.image(imageSource); + + this.mediaLayerRef.batchDraw(); + this.render(); + }; + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/RenderTask.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/RenderTask.ts new file mode 100644 index 0000000000..2f9cd6cf0a --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/RenderTask.ts @@ -0,0 +1,37 @@ +import { bool } from "@techstark/opencv-js"; + +export enum WorkerEvent { + START = "start", + PROGRESS = "progress", + COMPLETE = "complete", + DATA = "data", + ERROR = "error", +} + +export interface RenderTaskPayload { + offscreenCanvas: OffscreenCanvas; + id: number; +} + +// RenderSharedWorker.ts +export interface RenderTask { + jobID: number; + isDone: bool; + event: WorkerEvent; + data: RenderTaskPayload | undefined; +} + +export interface RenderTaskProgress { + isProcessing: boolean; + progress: number; + jobID: number; + event: WorkerEvent; +} + +export interface RenderTaskResult { + event: Worker; + jobID: number; + data: { + mediaUrl: string; + }; +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/note.txt b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/note.txt new file mode 100644 index 0000000000..36ee2bf8dc --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/RenderingPrimitives/note.txt @@ -0,0 +1,18 @@ + const dataURL = this.videoLayer.toDataURL({ + x: this.captureCanvas.x(), + y: this.captureCanvas.y(), + width: this.captureCanvas.width(), + height: this.captureCanvas.height(), + }); + + + // Create an image element to display the captured area + const img = new Image(); + img.src = dataURL; + document.body.appendChild(img); // Append the image to the body or any other container + + // Optionally, you can download the captured image + const link = document.createElement("a"); + link.href = dataURL; + link.download = "snapshot.png"; + link.click(); \ No newline at end of file diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/CreateCommand.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/CreateCommand.ts new file mode 100644 index 0000000000..41a752cf75 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/CreateCommand.ts @@ -0,0 +1,77 @@ +import Konva from "konva"; +import { ICommand } from "./ICommand"; +import { MediaNode } from "../types"; +import { + NodesManager, + NodeTransformer, + SelectionManager, +} from "../NodesManagers"; +import { RealTimeDrawEngine } from "../RenderingPrimitives/RealTimeDrawEngine"; +import { toolbarNode } from "../../signals/uiAccess/toolbarNode"; + +export class CreateCommand implements ICommand { + private mediaLayerRef: Konva.Layer; + private nodes: Set; + private zIndices: Map; + + private nodesManagerRef: NodesManager; + private nodeTransformerRef: NodeTransformer; + private selectionManagerRef: SelectionManager; + + private renderEngineRef: RealTimeDrawEngine; + + constructor({ + nodes, + mediaLayerRef, + nodesManagerRef, + nodeTransformerRef, + renderEngineRef, + selectionManagerRef, + }: { + nodes: Set; + mediaLayerRef: Konva.Layer; + nodesManagerRef: NodesManager; + nodeTransformerRef: NodeTransformer; + renderEngineRef: RealTimeDrawEngine; + selectionManagerRef: SelectionManager; + }) { + this.nodes = nodes; + this.zIndices = new Map(nodesManagerRef.getAllZIndices()); + + this.mediaLayerRef = mediaLayerRef; + this.nodesManagerRef = nodesManagerRef; + this.nodeTransformerRef = nodeTransformerRef; + this.renderEngineRef = renderEngineRef; + this.selectionManagerRef = selectionManagerRef; + } + + execute() { + this.nodes.forEach((node) => { + this.mediaLayerRef.add(node.kNode); + this.nodesManagerRef.saveNode(node); + this.renderEngineRef.addNodes(node); + }); + this.mediaLayerRef.draw(); + + } + + undo() { + this.nodes.forEach((node) => { + this.nodesManagerRef.removeNode(node); + this.renderEngineRef.removeNodes(node); + this.selectionManagerRef.deselectNode(node); + node.kNode.remove(); + if ( + this.zIndices.get(node) && + node.kNode.getZIndex() !== this.zIndices.get(node) + ) { + node.kNode.setZIndex(this.zIndices.get(node)); + } + }); + this.nodeTransformerRef.clear(); + toolbarNode.hide(); + this.mediaLayerRef.draw(); + this.renderEngineRef.render(); + + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/DeleteCommand.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/DeleteCommand.ts new file mode 100644 index 0000000000..cd44c4ba41 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/DeleteCommand.ts @@ -0,0 +1,77 @@ +import Konva from "konva"; +import { ICommand } from "./ICommand"; +import { MediaNode } from "../types"; +import { + NodesManager, + NodeTransformer, + SelectionManager, +} from "../NodesManagers"; +import { RealTimeDrawEngine as RenderEngine } from "../RenderingPrimitives/RealTimeDrawEngine"; + +import { toolbarNode } from "../../signals/uiAccess/toolbarNode"; + +export class DeleteCommand implements ICommand { + private mediaLayerRef: Konva.Layer; + private nodes: Set; + private zIndices: Map = new Map(); + + private nodesManagerRef: NodesManager; + private nodeTransformerRef: NodeTransformer; + private selectionManagerRef: SelectionManager; + + private renderEngineRef: RenderEngine; + + constructor({ + nodes, + mediaLayerRef, + nodesManagerRef, + nodeTransformerRef, + renderEngineRef, + selectionManagerRef, + }: { + nodes: Set; + mediaLayerRef: Konva.Layer; + nodesManagerRef: NodesManager; + nodeTransformerRef: NodeTransformer; + renderEngineRef: RenderEngine; + selectionManagerRef: SelectionManager; + }) { + this.nodes = new Set(nodes); + this.zIndices = new Map(nodesManagerRef.getAllZIndices()); + + this.mediaLayerRef = mediaLayerRef; + this.nodesManagerRef = nodesManagerRef; + this.nodeTransformerRef = nodeTransformerRef; + this.renderEngineRef = renderEngineRef; + this.selectionManagerRef = selectionManagerRef; + } + + execute() { + this.nodes.forEach((node) => { + this.nodesManagerRef.removeNode(node); + this.renderEngineRef.removeNodes(node); + this.selectionManagerRef.deselectNode(node); + node.kNode.remove(); + }); + this.nodeTransformerRef.clear(); + toolbarNode.hide(); + this.mediaLayerRef.draw(); + } + + undo() { + this.nodes.forEach((node) => { + this.mediaLayerRef.add(node.kNode); + this.nodesManagerRef.saveNode(node); + this.renderEngineRef.addNodes(node); + }); + this.nodesManagerRef.getAllNodes().forEach((node) => { + if ( + this.zIndices.get(node) && + node.kNode.getZIndex() !== this.zIndices.get(node) + ) { + node.kNode.setZIndex(this.zIndices.get(node)); + } + }); + this.mediaLayerRef.draw(); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/ICommand.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/ICommand.ts new file mode 100644 index 0000000000..35ccc223c0 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/ICommand.ts @@ -0,0 +1,4 @@ +export interface ICommand { + execute(): void | boolean | Promise; + undo(): void; +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/LockNodesCommand.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/LockNodesCommand.ts new file mode 100644 index 0000000000..4e5a557d54 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/LockNodesCommand.ts @@ -0,0 +1,19 @@ +import { MediaNode } from "../types"; +import { ICommand } from "./ICommand"; + +export class LockNodesCommand implements ICommand { + private nodes: Set; + constructor({ nodes }: { nodes: Set }) { + this.nodes = new Set(nodes); + } + execute() { + this.nodes.forEach((node) => { + node.lock(); + }); + } + undo() { + this.nodes.forEach((node) => { + node.unlock(); + }); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/MoveLayerDown.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/MoveLayerDown.ts new file mode 100644 index 0000000000..071ae3555c --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/MoveLayerDown.ts @@ -0,0 +1,64 @@ +import Konva from "konva"; +import { ICommand } from "./ICommand"; +import { MediaNode } from "../types"; +import { NodesManager } from "../NodesManagers"; + +export class MoveLayerDown implements ICommand { + private sortedMovingNodes: [MediaNode, number][]; + private sortedAllNodesByZ: [MediaNode, number][]; + private mediaLayerRef: Konva.Layer; + private nodesManagerRef: NodesManager; + + constructor({ + nodes, + mediaLayerRef, + nodesManagerRef, + }: { + nodes: Set; + mediaLayerRef: Konva.Layer; + nodesManagerRef: NodesManager; + }) { + this.sortedMovingNodes = Array.from(nodes) + .reduce( + (accNodes, currNode) => { + accNodes.push([currNode, currNode.kNode.getZIndex()]); + return accNodes; + }, + [] as [MediaNode, number][], + ) + // this sort the remapped array in ascending order + .sort(([, zA], [, zB]) => zA - zB); + + // set up references + this.sortedAllNodesByZ = nodesManagerRef.getSortedZIndices(); + this.mediaLayerRef = mediaLayerRef; + this.nodesManagerRef = nodesManagerRef; + } + + execute() { + let hasMovedNode = false; + let currMinIndex = 0; + this.sortedMovingNodes.forEach(([movingNode, movingNodeZindex]) => { + if (movingNodeZindex === currMinIndex) { + currMinIndex = currMinIndex + 1; + } else { + movingNode.moveLayerDown(); + hasMovedNode = true; + } + }); + this.nodesManagerRef.updateAllZIndices(); + this.mediaLayerRef.draw(); + // if (import.meta.env.DEV && !hasMovedNode) { + // console.log("No Node Moved-DOWN"); + // } + return hasMovedNode; + } + + undo() { + this.sortedAllNodesByZ.forEach(([node, zIndex]) => { + node.kNode.setZIndex(zIndex); + }); + this.nodesManagerRef.updateAllZIndices(); + this.mediaLayerRef.draw(); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/MoveLayerUp.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/MoveLayerUp.ts new file mode 100644 index 0000000000..2921ee594f --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/MoveLayerUp.ts @@ -0,0 +1,64 @@ +import Konva from "konva"; +import { ICommand } from "./ICommand"; +import { MediaNode } from "../types"; +import { NodesManager } from "../NodesManagers"; + +export class MoveLayerUp implements ICommand { + private sortedMovingNodes: [MediaNode, number][]; + private sortedAllNodesByZ: [MediaNode, number][]; + private mediaLayerRef: Konva.Layer; + private nodesManagerRef: NodesManager; + + constructor({ + nodes, + mediaLayerRef, + nodesManagerRef, + }: { + nodes: Set; + mediaLayerRef: Konva.Layer; + nodesManagerRef: NodesManager; + }) { + this.sortedMovingNodes = Array.from(nodes) + .reduce( + (accNodes, currNode) => { + accNodes.push([currNode, currNode.kNode.getZIndex()]); + return accNodes; + }, + [] as [MediaNode, number][], + ) + // this sort the remapped array in descending order + .sort(([, zA], [, zB]) => zB - zA); + + // set up references + this.sortedAllNodesByZ = nodesManagerRef.getSortedZIndices(); + this.mediaLayerRef = mediaLayerRef; + this.nodesManagerRef = nodesManagerRef; + } + + execute() { + let hasMovedNode = false; + let currMaxIndex = this.sortedAllNodesByZ.length; + this.sortedMovingNodes.forEach(([movingNode, movingNodeZindex]) => { + if (movingNodeZindex === currMaxIndex) { + currMaxIndex = currMaxIndex - 1; + } else { + movingNode.moveLayerUp(); + hasMovedNode = true; + } + }); + this.nodesManagerRef.updateAllZIndices(); + this.mediaLayerRef.draw(); + // if (import.meta.env.DEV && !hasMovedNode) { + // console.log("No Node Moved-UP"); + // } + return hasMovedNode; + } + + undo() { + this.sortedAllNodesByZ.forEach(([node, zIndex]) => { + node.kNode.setZIndex(zIndex); + }); + this.nodesManagerRef.updateAllZIndices(); + this.mediaLayerRef.draw(); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/TransformCommand.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/TransformCommand.ts new file mode 100644 index 0000000000..d9d1f6b865 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/TransformCommand.ts @@ -0,0 +1,82 @@ +import Konva from "konva"; +import { ICommand } from "./ICommand"; +import { MediaNode, Transformation } from "../types"; + +export class TransformCommand implements ICommand { + private nodes: Set; + private initialTransformations: Map; + private finalTransformations: Map; + private layerRef: Konva.Layer; + + constructor({ + nodes, + initialTransformations, + finalTransformations, + layerRef, + }: { + nodes: Set; + initialTransformations: Map; + finalTransformations: Map; + layerRef: Konva.Layer; + }) { + this.nodes = new Set(nodes); + this.initialTransformations = initialTransformations; + this.finalTransformations = finalTransformations; + this.layerRef = layerRef; + console.log("transform command", this); + } + private transformKNode(kNode: Konva.Node, transform: Transformation) { + kNode.setAttrs({ + position: transform.position, + size: transform.size, + rotation: transform.rotation, + scale: transform.scale, + }); + } + private findKNodeOfTransform = ( + node: MediaNode, + transform: Transformation, + ) => { + const parentKNode = node.kNode; + if (parentKNode._id === transform.kNodeId) { + this.transformKNode(parentKNode, transform); + return; + } + if (parentKNode instanceof Konva.Group) { + const childKNode = parentKNode + .getChildren() + .find((currChild) => currChild._id === transform.kNodeId); + if (childKNode) { + this.transformKNode(childKNode, transform); + return; + } + } + if (import.meta.env.DEV) { + console.warn("Error in Transform", node, transform); + } + }; + + execute() { + this.nodes.forEach((node) => { + const finalTransformations = this.finalTransformations.get(node); + if (finalTransformations) { + finalTransformations.forEach((transform) => + this.findKNodeOfTransform(node, transform), + ); + } + }); + this.layerRef.draw(); + } + + undo() { + this.nodes.forEach((node) => { + const initialTransformations = this.initialTransformations.get(node); + if (initialTransformations) { + initialTransformations.forEach((transform) => + this.findKNodeOfTransform(node, transform), + ); + } + }); + this.layerRef.draw(); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/TranslateCommand.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/TranslateCommand.ts new file mode 100644 index 0000000000..1cf40af44e --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/TranslateCommand.ts @@ -0,0 +1,46 @@ +import Konva from "konva"; +import { ICommand } from "./ICommand"; +import { MediaNode, Position } from "../types"; + +export class TranslateCommand implements ICommand { + private nodes: Set; + private initialPositions: Map; + private finalPositions: Map; + private layerRef: Konva.Layer; + + constructor({ + nodes, + initialPositions, + finalPositions, + layerRef, + }: { + nodes: Set; + initialPositions: Map; + finalPositions: Map; + layerRef: Konva.Layer; + }) { + this.nodes = new Set(nodes); + this.initialPositions = initialPositions; + this.finalPositions = finalPositions; + this.layerRef = layerRef; + } + execute() { + this.nodes.forEach((node) => { + const finalPosition = this.finalPositions.get(node); + if (finalPosition) { + node.kNode.position(finalPosition); + } + }); + this.layerRef.draw(); + } + + undo() { + this.nodes.forEach((node) => { + const initialPosition = this.initialPositions.get(node); + if (initialPosition) { + node.kNode.position(initialPosition); + } + }); + this.layerRef.draw(); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/UndoRedoManager.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/UndoRedoManager.ts new file mode 100644 index 0000000000..a0cf47cf4f --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/UndoRedoManager.ts @@ -0,0 +1,79 @@ +import { ICommand } from "./ICommand"; + +export class UndoStackManager { + private disabled: boolean = false; + private undoStack: ICommand[] = []; + private redoStack: ICommand[] = []; + private readonly STACK_LIMIT = 20; + private onCommandExecuted: () => void; + + constructor(onCommandExecuted: () => void) { + this.onCommandExecuted = onCommandExecuted; + } + + setDisabled(newState: boolean) { + this.disabled = newState; + } + isDisabled() { + return this.disabled; + } + async executeCommand(command: ICommand) { + const result = await command.execute(); + if (result !== undefined && result === false) { + //command is not executed if it deliberatly returned false; + //in that case, no need to do anything + return; + } + this.pushCommand(command); + } + pushCommand(command: ICommand) { + this.undoStack.push(command); + this.redoStack = []; // Clear the redo stack + + // Ensure the undo stack does not exceed the limit + if (this.undoStack.length > this.STACK_LIMIT) { + this.undoStack.shift(); // Remove the oldest command + } + console.log("command stack:", this.undoStack); + } + clear() { + this.undoStack = []; + this.redoStack = []; + } + undo() { + if (this.disabled) { + return; + } + const command = this.undoStack.pop(); + if (command) { + command.undo(); + this.redoStack.push(command); + + // Ensure the redo stack does not exceed the limit + if (this.redoStack.length > this.STACK_LIMIT) { + this.redoStack.shift(); // Remove the oldest command + } + console.log("undo:", this.undoStack); + } + this.onCommandExecuted(); + } + + redo() { + if (this.disabled) { + return; + } + const command = this.redoStack.pop(); + if (command) { + command.execute(); + this.undoStack.push(command); + + // Ensure the undo stack does not exceed the limit + if (this.undoStack.length > this.STACK_LIMIT) { + this.undoStack.shift(); // Remove the oldest command + } + + console.log("Redo:", this.redoStack); + } + this.onCommandExecuted(); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/UnlockNodesCommand.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/UnlockNodesCommand.ts new file mode 100644 index 0000000000..ea60b7ade7 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/UnlockNodesCommand.ts @@ -0,0 +1,19 @@ +import { MediaNode } from "../types"; +import { ICommand } from "./ICommand"; + +export class UnlockNodesCommand implements ICommand { + private nodes: Set; + constructor({ nodes }: { nodes: Set }) { + this.nodes = new Set(nodes); + } + execute() { + this.nodes.forEach((node) => { + node.unlock(); + }); + } + undo() { + this.nodes.forEach((node) => { + node.lock(); + }); + } +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/index.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/index.ts new file mode 100644 index 0000000000..7b6eea9e30 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/UndoRedo/index.ts @@ -0,0 +1,9 @@ +export { UndoStackManager } from "./UndoRedoManager"; +export { CreateCommand } from "./CreateCommand"; +export { DeleteCommand } from "./DeleteCommand"; +export { LockNodesCommand } from "./LockNodesCommand"; +export { MoveLayerUp } from "./MoveLayerUp"; +export { MoveLayerDown } from "./MoveLayerDown"; +export { TransformCommand } from "./TransformCommand"; +export { TranslateCommand } from "./TranslateCommand"; +export { UnlockNodesCommand } from "./UnlockNodesCommand"; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/constants.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/constants.ts new file mode 100644 index 0000000000..b8ba2b5358 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/constants.ts @@ -0,0 +1,25 @@ +export enum AppModes { + INIT = "INIT", + PREVIEW = "PREVIEW", + SELECT = "SELECT", + PAINT = "PAINT", + ERASER = "ERASER", + RENDERING = "RENDERING", +} + +export enum EditModes { + SELECT = "SELECT", + EDIT = "EDIT", +} + +export const VideoResolutions = { + VERTICAL_720: { width: 720, height: 1280 }, + LANDSCAPE_720: { width: 1280, height: 720 }, + SQUARE_1024: { width: 1024, height: 1024 }, +}; + +export const Colors = { + transparent: "rgba(0,0,0,0)", + translucentBlack: "rgba(0,0,0,0.3)", + primaryOrange: "rgba(45, 129, 255, 1)", +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/index.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/index.ts new file mode 100644 index 0000000000..a7f5f29a1b --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/index.ts @@ -0,0 +1,11 @@ +// to do fix https://codesandbox.io/p/sandbox/react-konva-infinite-grid-kkndq?file=%2Fsrc%2Findex.js the dotted background doesn't move when draggable. +import { EditEngine } from "./EditEngine"; +import { Engine } from "../Engine"; +import { EngineOptions } from "./types"; +export { Engine as EngineType }; + +export const KonvaApp = (element: HTMLDivElement, options: EngineOptions) => { + const engine = new Engine(element, options); + engine.initializeStage(options.sceneToken); + return engine; +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/NodeData.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/NodeData.ts new file mode 100644 index 0000000000..1e06ead979 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/NodeData.ts @@ -0,0 +1,42 @@ +import { NodeType, BaseNode, ImageNode, TextNode, ShapeNode } from "../Nodes"; + +import { TransformationData } from "./Transformation"; + +import { TextNodeData } from "./Text"; +import { PaintNode } from "../Nodes/PaintNode"; + +export type MediaNode = + // | NetworkedNode + BaseNode | ImageNode | TextNode | ShapeNode | PaintNode; + +export type ImageNodeData = { + mediaFileUrl: string; + mediaFileToken?: string; +}; +export type VideoNodeData = { + mediaFileUrl: string; + mediaFileToken?: string; + videoWidth?: number; + videoHeight?: number; + isChroma: boolean; + chromaColor: { + red: number; + green: number; + blue: number; + }; + extractionUrl?: string; +}; + +export type ShapeNodeData = { + shape: "triangle" | "square" | "circle"; +} + +export type NodeData = { + type: NodeType; + transform: TransformationData; + + // Text Node Data + imageNodeData?: ImageNodeData; + textNodeData?: TextNodeData; + videoNodeData?: VideoNodeData; +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/Text.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/Text.ts new file mode 100644 index 0000000000..33fbe2d6f1 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/Text.ts @@ -0,0 +1,32 @@ +export enum FontWeight { + NORMAL = "normal", + BOLD = "bold", +} +export enum FontStyle { + NORMAL = "normal", + ITALIC = "italic", +} +export enum TextAlign { + LEFT = "left", + RIGHT = "right", + CENTER = "center", + // JUSTIFY = "justify", +} +export enum TextDecoration { + NONE = "none", + STRIKETHROUGH = "line-through", + UNDERLINE = "underline", +} +export type TextFormatData = { + color: string; + maxWidth: number; + fontFamily: string; + fontSize: number; + fontStyle: FontStyle; + fontWeight: FontWeight; + textAlign: TextAlign; + textDecoration: TextDecoration; +}; +export type TextNodeData = { + text: string; +} & TextFormatData; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/Transformation.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/Transformation.ts new file mode 100644 index 0000000000..2b4885417c --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/Transformation.ts @@ -0,0 +1,16 @@ +import { Position, Size, Scale } from "./basicValueTypes"; + +// This is meant for the transformation command +export interface Transformation { + kNodeId: number | string; + position: Position; + size: Size; + scale: Scale; + rotation: number; +} + +// This is meant for saving transformation data +// for scene saving and loading +export interface TransformationData extends Omit { + zIndex: number; +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/basicValueTypes.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/basicValueTypes.ts new file mode 100644 index 0000000000..c4e20e8745 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/basicValueTypes.ts @@ -0,0 +1,14 @@ +import { Vector2d } from "konva/lib/types"; + +export interface Position extends Vector2d {} +export interface Scale extends Vector2d {} +export interface Size { + width: number; + height: number; +} + +export interface RGBColor { + red: number; + green: number; + blue: number; +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/events.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/events.ts new file mode 100644 index 0000000000..36869e5fed --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/events.ts @@ -0,0 +1,22 @@ +import { NetworkedNode } from "../Nodes/NetworkedNode"; + +export interface ProgressEventDetails { + progress: number; + message?: string; + status?: S; +} +export interface NodeProgressEventDetail extends ProgressEventDetails { + node: NetworkedNode; +} + +export enum VideoExtractionEvents { + "SESSION_CREATING" = "SESSION_CREATING", + "SESSION_IDLE" = "SESSION_IDLE", + "EXTRACTION_POINT_REQUEST" = "EXTRACTION_POINT_REQUEST", + "SESSION_CLOSING" = "SESSION_CLOSING", + "SESSION_CLOSED" = "SESSION_CLOSED", +} +export interface VideoExtractionEventDetails + extends ProgressEventDetails { + status: VideoExtractionEvents; +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/index.ts b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/index.ts new file mode 100644 index 0000000000..396d57cfe4 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaApp/types/index.ts @@ -0,0 +1,10 @@ +import { NavigateFunction } from "react-router-dom"; +export interface EngineOptions { + navigate: NavigateFunction; + sceneToken?: string; +} + +export * from "./basicValueTypes"; +export * from "./NodeData"; +export * from "./Text"; +export * from "./Transformation"; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaCanvasContainer.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaCanvasContainer.tsx index 8d6203f09e..0c291ebefa 100644 --- a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaCanvasContainer.tsx +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaCanvasContainer.tsx @@ -1,13 +1,13 @@ import { forwardRef, HTMLAttributes } from "react"; import { twMerge } from "tailwind-merge"; -import { useRenderCounter } from "~/hooks/useRenderCounter"; +// import { useRenderCounter } from "~/hooks/useRenderCounter"; export const KonvaCanvasContainer = forwardRef< HTMLDivElement, HTMLAttributes >(({ className, ...props }, ref) => { - useRenderCounter("KonvaCanvasContainer"); + // useRenderCounter("KonvaCanvasContainer"); const classes = twMerge("pegboard -z-10", className); return
; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaRootComponent.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaRootComponent.tsx index f20fb2a59b..f63fe43712 100644 --- a/frontend/apps/editor3d/app/src/pages/Page2d/KonvaRootComponent.tsx +++ b/frontend/apps/editor3d/app/src/pages/Page2d/KonvaRootComponent.tsx @@ -10,15 +10,15 @@ import { SignaledToolbarMain } from "./SignaledToolbarMain"; // import { PromptBox } from "~/components/PromptBox"; // The KonvaApp is the root of the Konva stage // and only entry point for anything in Konva JS -import { EngineType } from "~/KonvaApp"; -import { KonvaApp } from "~/KonvaApp"; +import { EngineType } from "./KonvaApp"; +import { KonvaApp } from "./KonvaApp"; // all the signal-contexts are wrapped in hooks -import { useAppUiContext } from "./contextSignals/appUi"; not needed for 2d. +import { useAppUiContext } from "./contextSignals/appUi"; import { useLayoutContext } from "./contextSignals/layout"; // common hooks -import { useRenderCounter } from "~/hooks/useRenderCounter"; +import { useRenderCounter } from "./useRenderCounter"; import { useNavigate } from "react-router-dom"; // TODO REFACTOR THIS INTO THE COMPONENT LIBRARY its used in 3d and 2d @@ -35,7 +35,7 @@ export const KonvaRootComponent = ({ // Let's make sure we only log once useRenderCounter("KonvaRootComponent"); const navigate = useNavigate(); - // const appUiContext = useAppUiContext(); + const appUiContext = useAppUiContext(); const layoutContext = useLayoutContext(); // Add state to track engine initialization diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/SignaledDialogs.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/SignaledDialogs.tsx index 9c77486627..d9f8709def 100644 --- a/frontend/apps/editor3d/app/src/pages/Page2d/SignaledDialogs.tsx +++ b/frontend/apps/editor3d/app/src/pages/Page2d/SignaledDialogs.tsx @@ -5,10 +5,10 @@ import { DialogAddImage, DialogEditText, DialogError, -} from "~/components/features"; +} from "./components/features"; import { AppUiSignalType } from "./contextSignals/appUi"; -import { dialogError } from "~/signals/uiAccess/dialogError"; +import { dialogError } from "./signals/uiAccess/dialogError"; export const SignaledDialogs = ({ diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/SignaledToolbarMain.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/SignaledToolbarMain.tsx new file mode 100644 index 0000000000..e8188b8393 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/SignaledToolbarMain.tsx @@ -0,0 +1,97 @@ +import { MouseEventHandler } from "react"; +// import { useSignalEffect } from "@preact/signals-react"; +import { Transition } from "@headlessui/react"; +import { toolbarMain } from "./signals/uiAccess/toolbarMain"; +import { dispatchers } from "./signals/uiEvents/toolbarMain"; + +import { ToolbarMain } from "./components/features/ToolbarMain"; +import { LoadingBar } from "./components/ui/LoadingBar"; + +import { ToolbarMainButtonNames } from "./components/features/ToolbarMain/enum"; +import { LayoutSignalType } from "./contextSignals/layout"; +import { AppUiContextInterface } from "./contextSignals/appUi"; +import { twMerge } from "tailwind-merge"; +import { paperWrapperStyles } from "./components/styles"; +import { dispatchUiEvents } from "./signals/uiEvents"; + +export const SignaledToolbarMain = ({ + // layoutSignal, + appUiContext, +}: { + layoutSignal: LayoutSignalType; + appUiContext: AppUiContextInterface; +}) => { + + const loadingBar = toolbarMain.loadingBar.signal.value; + const getButtonDispatcher = (buttonName: ToolbarMainButtonNames) => { + switch (buttonName) { + case ToolbarMainButtonNames.ADD_TEXT: + return () => appUiContext.openEditText(); + case ToolbarMainButtonNames.ADD_IMAGE: + return () => appUiContext.openAddImage(); + case ToolbarMainButtonNames.ADD_VIDEO: + return () => appUiContext.openAddVideo(); + case ToolbarMainButtonNames.ADD_CIRCLE: + return () => dispatchUiEvents.addShapeToEngine({ shape: "circle" }); + case ToolbarMainButtonNames.ADD_TRIANGLE: + return () => dispatchUiEvents.addShapeToEngine({ shape: "triangle" }); + case ToolbarMainButtonNames.ADD_SQUARE: + return () => dispatchUiEvents.addShapeToEngine({ shape: "square" }); + case ToolbarMainButtonNames.SELECT: + case ToolbarMainButtonNames.ERASER: + case ToolbarMainButtonNames.PAINT: + return (e: React.MouseEvent) => { + toolbarMain.setMode(buttonName); + dispatchers[buttonName](e); + }; + default: + return dispatchers[buttonName]; + } + }; + const buttonProps = Object.values(ToolbarMainButtonNames).reduce( + (acc, buttonName) => { + acc[buttonName] = { + disabled: toolbarMain.signal.value.buttonStates[buttonName].disabled, + active: toolbarMain.signal.value.buttonStates[buttonName].active, + onClick: getButtonDispatcher(buttonName), + }; + return acc; + }, + {} as { + [key in ToolbarMainButtonNames]: { + disabled: boolean; + active: boolean; + onClick: MouseEventHandler; + }; + }, + ); + + const handleOnClickRetry = (e: React.MouseEvent) => { + dispatchers.loadingBarRetry(e); + }; + return ( +
+ + + + + +
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogAddImage/DialogAddImage.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogAddImage/DialogAddImage.tsx new file mode 100644 index 0000000000..596c18145f --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogAddImage/DialogAddImage.tsx @@ -0,0 +1,76 @@ +import { useRef, useState } from "react"; +import { DialogTitle } from "@headlessui/react"; + +// TODO Port the file uploader to the new ui library +import { FileUploader } from "../../../components/reusable/FileUploader"; +import { Button } from "@storyteller/ui-button"; + +import { IMAGE_FILE_TYPE } from "../../../constants/fileTypeEnums"; + +import { BaseDialog } from "../../../components/ui/BaseDialog"; + +export const DialogAddImage = ({ + stagedImage = null, + isOpen, + closeCallback, + onAddImage, +}: { + stagedImage?: File | null; + isOpen: boolean; + closeCallback: () => void; + onAddImage: (file: File) => void; +}) => { + const [assetFile, setAssetFile] = useState(null); + const previouslyStagedImageRef = useRef(null); + + const currFile = + stagedImage && + stagedImage !== assetFile && + stagedImage !== previouslyStagedImageRef.current + ? stagedImage + : assetFile; + if (previouslyStagedImageRef.current !== stagedImage) { + previouslyStagedImageRef.current = stagedImage; + } + + function handleClose() { + setAssetFile(null); + closeCallback(); + } + + return ( + + Upload Image +
+ { + setAssetFile(file); + if (file) { + onAddImage(file); + handleClose(); + } + }} + /> + {currFile && ( +
+ +
+ )} +
+
+ +
+
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogAddImage/index.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogAddImage/index.tsx new file mode 100644 index 0000000000..e4bca7cef3 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogAddImage/index.tsx @@ -0,0 +1 @@ +export * from "./DialogAddImage"; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogEditText/DialogEditText.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogEditText/DialogEditText.tsx new file mode 100644 index 0000000000..e1587ca10d --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogEditText/DialogEditText.tsx @@ -0,0 +1,117 @@ +export enum FontWeight { + NORMAL = "normal", + BOLD = "bold", +} +export enum FontStyle { + NORMAL = "normal", + ITALIC = "italic", +} +export enum TextAlign { + LEFT = "left", + RIGHT = "right", + CENTER = "center", + // JUSTIFY = "justify", +} +export enum TextDecoration { + NONE = "none", + STRIKETHROUGH = "line-through", + UNDERLINE = "underline", +} +export type TextFormatData = { + color: string; + maxWidth: number; + fontFamily: string; + fontSize: number; + fontStyle: FontStyle; + fontWeight: FontWeight; + textAlign: TextAlign; + textDecoration: TextDecoration; +}; +export type TextNodeData = { + text: string; +} & TextFormatData; + + +import { useState } from "react"; + +import { DialogTitle } from "@headlessui/react"; +import { Button, TextEditor } from "../../../components/ui"; + +import { BaseDialog } from "../../../components/ui/BaseDialog"; + + +export const DialogEditText = ({ + isOpen, + closeCallback, + onDoneEditText, +}: { + isOpen: boolean; + closeCallback: () => void; + onDoneEditText: (doneData: TextNodeData) => void; +}) => { + const [text, setText] = useState(""); + const [textFormatData, setTextFormatData] = useState({ + color: "#000000", + maxWidth: 500, + fontFamily: "Arial", + fontSize: 20, + fontStyle: FontStyle.NORMAL, + fontWeight: FontWeight.NORMAL, + textAlign: TextAlign.CENTER, + textDecoration: TextDecoration.NONE, + }); + + const handleOnChangeText = (newText: string) => { + setText(newText); + }; + const handleOnChangeFormatting = (newFormatData: Partial) => { + setTextFormatData((curr) => ({ + ...curr, + ...newFormatData, + })); + }; + const handleOnDoneEditText = () => { + const textNodeData = { + text: text, + ...textFormatData, + // fill: textFormatData.color, + // fontFamily: textFormatData.fontFamily, + // fontSize: textFormatData.fontSize, + // align: textFormatData.textAlign, + // fontStyle: + // textFormatData.fontStyle === FontStyle.NORMAL && + // textFormatData.fontWeight === FontWeight.NORMAL + // ? "normal" + // : `${textFormatData.fontWeight !== FontWeight.NORMAL ? textFormatData.fontWeight : ""} ${textFormatData.fontStyle !== FontStyle.NORMAL ? textFormatData.fontStyle : ""}`, + // textDecoration: + // textFormatData.textDecoration === TextDecoration.NONE + // ? "" + // : textFormatData.textDecoration, + }; + onDoneEditText(textNodeData); + closeCallback(); + }; + return ( + + Edit Text + +
+ + +
+
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogEditText/index.ts b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogEditText/index.ts new file mode 100644 index 0000000000..6ac727535e --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogEditText/index.ts @@ -0,0 +1 @@ +export { DialogEditText } from "./DialogEditText"; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogError/index.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogError/index.tsx new file mode 100644 index 0000000000..21de24579e --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/DialogError/index.tsx @@ -0,0 +1,36 @@ +import { twMerge } from "tailwind-merge"; + +import { dialogPanelStyles, paperWrapperStyles } from "../../../components/styles"; +import { + Description, + Dialog, + DialogPanel, + DialogTitle, +} from "@headlessui/react"; +import { Button } from "../../../components/ui"; + +export const DialogError = ({ + isShowing, + title, + message, + onClose, +}: { + isShowing: boolean; + title: string; + message: string; + onClose: () => void; +}) => { + return ( + +
+ + {title} + {message} +
+ +
+
+
+
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarButton.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarButton.tsx new file mode 100644 index 0000000000..f8b7bd8edb --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarButton.tsx @@ -0,0 +1,90 @@ +import { ButtonHTMLAttributes, MouseEventHandler } from "react"; +import { twMerge } from "tailwind-merge"; +import { + FontAwesomeIcon, + FontAwesomeIconProps, +} from "@fortawesome/react-fontawesome"; +import { IconDefinition } from "@fortawesome/pro-solid-svg-icons"; + +import { Tooltip } from "../../components/ui/Tooltip"; + +export interface ToolbarButtonProps + extends ButtonHTMLAttributes { + active?: boolean; + hidden?: boolean; + prominent?: boolean; +} + +export const ToolbarButton = ({ + children, + icon, + tooltip, + onClick, + buttonProps = {}, + iconProps, +}: { + children?: React.ReactNode; + icon: IconDefinition; + tooltip?: string; + onClick?: MouseEventHandler; + buttonProps?: ToolbarButtonProps; + iconProps?: Omit; +}) => { + const { + className: customButtonClassNames, + disabled, + active, + hidden, + onClick: customOnClick, + ...restButtonProps + } = buttonProps; + + if (hidden) { + return null; + } + + const mergedButtonClasses = twMerge( + "rounded-lg hover:bg-white/15 transition-all duration-100 border-2 border-transparent text-white/80 px-2", + children ? "w-fit flex items-center gap-2.5 text-nowrap" : "size-10", + active && "bg-primary/30 border-2 border-primary hover:bg-primary/30", + disabled && "pointer-events-none text-secondary-300", + buttonProps.prominent && + "border border-primary-400/30 text-primary-500 hover:bg-primary-100/40 hover:border-primary-400/60", + customButtonClassNames, + ); + + const Button = ( + + ); + if (tooltip) { + return ( + + {Button} + + ); + } + return Button; +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/ButtonPreviewAndRender.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/ButtonPreviewAndRender.tsx new file mode 100644 index 0000000000..20f52b33dc --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/ButtonPreviewAndRender.tsx @@ -0,0 +1,74 @@ +import { faCameraMovie, faPlayCircle } from "@fortawesome/pro-solid-svg-icons"; +import { Button, Tooltip } from "~/components/ui"; +import { ToolbarButtonProps } from "../ToolbarButton"; + +export const ButtonPreviewAndRender = ({ + buttonPreviewProps, + buttonRenderProps, +}: { + buttonPreviewProps: ToolbarButtonProps; + buttonRenderProps: ToolbarButtonProps; +}) => { + return ( +
+
+ ); +}; +const ButtonPreview = (buttonProps: ToolbarButtonProps) => { + const { + className: customButtonClassNames, + disabled, + active, + hidden, + onClick, + ...restButtonProps + } = buttonProps; + return ( + + + + ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/ToolbarMain.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/ToolbarMain.tsx new file mode 100644 index 0000000000..ece0f8320e --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/ToolbarMain.tsx @@ -0,0 +1,267 @@ +import { + faArrowRotateLeft, + faArrowRotateRight, + faCircle, + faEraser, + faFilePlus, + faImage, + faImages, + faLocationArrow, + faPaintbrush, + faShapes, + faSquare, + faText, + faTriangle, +} from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; +import { Fragment, MouseEventHandler } from "react"; +import { twMerge } from "tailwind-merge"; + +import { ToolbarButton } from "../../../components/features/ToolbarButton"; +import { Tooltip } from "~/components/ui/Tooltip"; + +// style and constants +import { paperWrapperStyles, toolTipStyles } from "../../../components/styles"; +import { ToolbarMainButtonNames } from "./enum"; +import { dispatchUiEvents, uiEvents } from "../../../signals/uiEvents"; +import { paintColor } from "../../../signals/uiEvents/toolbarMain/paintMode"; +import { ColorPicker } from "../../../components/ui/TextEditor/ColorPicker"; +import { PaintModeMenu } from "../../../components/ui/ToolbarMain/PaintModeMenu"; +import { EraseModeMenu } from "../../../components/ui/ToolbarMain/EraseModeMenu"; +import { BackgroundMenu } from "../../../components/ui/ToolbarMain/BackgroundMenu"; +import { bgColor } from "../../../signals/uiEvents/toolbarMain/backgroundMenu"; + +export const ToolbarMain = ({ + disabled = false, + buttonProps, +}: { + disabled?: boolean; + buttonProps: { + [key in ToolbarMainButtonNames]: { + disabled?: boolean; + active?: boolean; + onClick?: MouseEventHandler; + }; + }; +}) => { + return ( + <> +
+
+ + + + {({ close }) => ( + <> + + + + + { + buttonProps.ADD_SQUARE.onClick?.(e); + close(); + }, + }} + /> + { + buttonProps.ADD_CIRCLE.onClick?.(e); + close(); + }, + }} + /> + { + buttonProps.ADD_TRIANGLE.onClick?.(e); + close(); + }, + }} + /> + + + )} + + + + + + + + + Add Image + + {/* + Add Video + */} + + +
+ +
+ +
+
+ {/* Conditionally show paint menu on mode selection */} + {buttonProps.PAINT.active ? ( +
+ +
+
+ ) : ( + + )} + + {/* Conditionally show eraser menu on mode selection */} + {/* {buttonProps.ERASER.active ? ( + + ) : ( + + )} */} +
+
+ +
+ +
+ + + {/* */} +
+ +
+ +
+
+ {/* +
+ +
+
+ */} +
+
+
+ + ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/enum.ts b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/enum.ts new file mode 100644 index 0000000000..13ccd6c6a8 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/enum.ts @@ -0,0 +1,22 @@ +export enum ToolbarMainButtonNames { + MENU = "MENU", + SELECT = "SELECT", + ADD_TEXT = "ADD_TEXT", + ADD_IMAGE = "ADD_IMAGE", + ADD_VIDEO = "ADD_VIDEO", + ADD_CIRCLE = "ADD_CIRCLE", + ADD_TRIANGLE = "ADD_TRIANGLE", + ADD_SQUARE = "ADD_SQUARE", + PAINT = "PAINT", + ERASER = "ERASER", + AI_STYLIZE = "AI_STYLIZE", + UNDO = "UNDO", + REDO = "REDO", + SAVE = "SAVE", + PREVIEW = "PREVIEW", + DOWNLOAD = "DOWNLOAD", + BACKGROUND = "BACKGROUND", + + // 4o generation + GENERATE = "GENERATE", +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/index.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/index.tsx new file mode 100644 index 0000000000..499b972e8f --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarMain/index.tsx @@ -0,0 +1 @@ +export { ToolbarMain } from "./ToolbarMain"; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarNode/ToolbarNode.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarNode/ToolbarNode.tsx index 019145373d..d175a7596c 100644 --- a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarNode/ToolbarNode.tsx +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/ToolbarNode/ToolbarNode.tsx @@ -4,12 +4,12 @@ import { faPalette } from "@fortawesome/pro-solid-svg-icons"; import { ToolbarButton, ToolbarButtonProps, -} from "~/components/features/ToolbarButton"; -import { paperWrapperStyles } from "~/components/styles"; +} from "../../../components/features/ToolbarButton"; +import { paperWrapperStyles } from "../../../components/styles"; import { ToolbarNodeButtonNames } from "./enums"; import { ToolbarNodeButtonData } from "./data"; -import { ColorPicker } from "~/components/ui/TextEditor/ColorPicker"; -import { DEFAULT_PAINT_COLOR } from "~/signals/uiEvents/toolbarMain/paintMode"; +import { ColorPicker } from "../../../components/ui/TextEditor/ColorPicker"; +import { DEFAULT_PAINT_COLOR } from "../../../signals/uiEvents/toolbarMain/paintMode"; export interface ToolbarNodeProps { disabled?: boolean; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/features/index.ts b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/index.ts new file mode 100644 index 0000000000..02364b21c5 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/features/index.ts @@ -0,0 +1,5 @@ +export { DialogAddImage } from "./DialogAddImage"; +export { DialogError } from "./DialogError"; +export { ToolbarMain } from "./ToolbarMain"; +export { ToolbarNode } from "./ToolbarNode"; +export { DialogEditText } from "./DialogEditText"; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/DragAndDropZone.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/DragAndDropZone.tsx new file mode 100644 index 0000000000..b4652f1ea5 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/DragAndDropZone.tsx @@ -0,0 +1,52 @@ +import { faFileArrowUp, faFileAudio } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { P } from "~/components"; + +export const DragAndDropZone = ({ + file, + fileTypes, +}: { + file?: File; + fileTypes: string[]; +}) => { + if (!file) { + return ( +
+ +
+

+ Upload a file or drop it here +

+ +

+ {fileTypes.join(", ").toString()} supported +

+
+
+ ); + } + + const fileName = file.name.split(".")[0].toUpperCase(); + const fileSize = + file.size >= 1024 * 1024 + ? (file.size / 1024 / 1024).toFixed(2) + " MB" + : `${Math.floor(file.size / 1024)} KB`; + + return ( +
+ +
+

+ {file.name.slice(0, file.name.lastIndexOf("."))} +

+

+ + {`${fileName} file size: ${fileSize} `} + + Change File +

+
+
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/FileUploader.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/FileUploader.tsx new file mode 100644 index 0000000000..196bd12a3e --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/FileUploader.tsx @@ -0,0 +1,23 @@ +import { FileUploader as DragDropFileUploader } from "react-drag-drop-files"; +// Usage refer to https://github.com/KarimMokhtar/react-drag-drop-files + +import { DragAndDropZone } from "./DragAndDropZone"; + +export const FileUploader = ({ + file, + fileTypes, + handleChange, +}: { + file: File | undefined; + handleChange: (file: File) => void; + fileTypes: string[]; +}) => ( + + + +); diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/index.ts b/frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/index.ts new file mode 100644 index 0000000000..75ffa32865 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/reusable/FileUploader/index.ts @@ -0,0 +1 @@ +export * from "./FileUploader"; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/Activity.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/Activity.tsx new file mode 100644 index 0000000000..c9dc4510c4 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/Activity.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from "react"; +import { faBell, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { PopoverMenu } from "~/components/reusable/Popover"; +import { CompletedCard } from "./CompletedCard"; +import { InProgressCard } from "./InProgressCard"; +import { useJobContext } from "~/components/JobContext"; +import { Api } from "~/KonvaApp/Api"; +import { toast } from "sonner"; +import { Tooltip } from "@storyteller/ui-tooltip"; + +// Dummy data types +interface CompletedItem { + token: string; + maybe_title: string; + public_bucket_path: string; + updated_at: string; +} + +interface ActiveJob { + job_token: string; + request: { + maybe_model_title: string; + }; + status: { + status: string; + progress_percentage: number; + }; +} + +export function Activity() { + const { jobTokens, removeJobToken } = useJobContext(); + const [completedItems, setCompletedItems] = useState([]); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const pollData = async () => { + if (jobTokens.length > 0) { + console.log("Current Job Tokens:", jobTokens); + const api = new Api(); + + // Create a map to store the latest job data + const updatedJobs = new Map(); + + for (const jobToken of jobTokens) { + const job = await api.pollJobSession(jobToken); + + if (job.success && job.data) { + const { status, progress_percentage } = job.data.status; + const { job_token } = job.data; + + if ( + status.toLowerCase() === "complete_success" || + status.toLowerCase() === "failed" + ) { + // If job is complete_success or failed, we'll remove it later + removeJobToken(job_token); + + // Add to complete_success items if it was successful + if (status.toLowerCase() === "complete_success") { + const newCompletedItem: CompletedItem = { + token: job_token, + maybe_title: + job.data.request.maybe_model_title || "Image Generation", + public_bucket_path: job.data.result.image_url || "", + updated_at: new Date().toISOString(), + }; + toast.success("Done Processing Image"); + setCompletedItems((prev) => [newCompletedItem, ...prev]); + } + } else { + // Store the job in our map + const updatedJob: ActiveJob = { + job_token, + request: { + maybe_model_title: + job.data.request.maybe_model_title || "Image Generation", + }, + status: { + status: status.toUpperCase(), + progress_percentage: progress_percentage || 0, + }, + }; + updatedJobs.set(job_token, updatedJob); + } + } else { + console.error("Failed to fetch job status:", job.errorMessage); + } + } + + // Update jobs state with the deduplicated jobs + setJobs(Array.from(updatedJobs.values())); + } + if (jobTokens.length === 0) { + setJobs([]); + setLoading(false); + } + }; + + // Initial load + // Initial call to pollData + pollData(); + + // Set up polling interval (every 5 seconds) + const interval = setInterval(async () => { + await pollData(); + }, 5000); + + return () => clearInterval(interval); + }, [jobTokens, removeJobToken]); + + return ( + + + + <> + {jobs.length > 0 ? ( + + ) : ( + + )} + + + {jobs.length > 0 && ( +
+ )} +
+ } + > +
+ {/* In progress */} + {jobs.length > 0 && ( +
+ {jobs.map((job) => ( + + ))} +
+ )} + + {loading ? ( +
+ +

Retrieving Activities

+
+ ) : completedItems.length === 0 && jobs.length === 0 ? ( +
+

No activities yet

+
+ ) : ( +
+ {/* Completed */} +
+ {completedItems.map((item) => ( + + ))} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/CompletedCard.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/CompletedCard.tsx new file mode 100644 index 0000000000..5c2994c805 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/CompletedCard.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import dayjs from "dayjs"; +import { BaseDialog } from "../BaseDialog"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faXmark } from "@fortawesome/pro-solid-svg-icons"; + +interface Props { + job: { + token: string; + maybe_title: string; + public_bucket_path: string; + updated_at: string; + maybe_style_name?: string; + }; +} + +export function CompletedCard({ job }: Props) { + const [loadError, setLoadError] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const imageUrl = job.public_bucket_path; + const fullImageUrl = job.public_bucket_path; + console.log("I am hjere"); + console.log(job); + + return ( + <> +
setIsModalOpen(true)} + > +
+
+ {job.maybe_title setLoadError(true)} + loading="lazy" + /> +
+
+
{job.maybe_title || "Untitled"}
+
+ {dayjs(job.updated_at).format("MMM D, YYYY HH:mm:ss")} +
+
+
+
+ + setIsModalOpen(false)} + className="h-[80vh] w-[75vw] max-w-[75vw] p-0" + > +
+ +
+ {job.maybe_title setLoadError(true)} + /> +
+
+
+ {job.maybe_title || "Untitled"} +
+
+ Created: {dayjs(job.updated_at).format("MMM D, YYYY HH:mm:ss")} +
+ {job.maybe_style_name && ( +
+ Style: {job.maybe_style_name} +
+ )} +
+
+
+ + ); +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/InProgressCard.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/InProgressCard.tsx new file mode 100644 index 0000000000..9579161fb7 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/InProgressCard.tsx @@ -0,0 +1,78 @@ +import { useCallback } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faClose, + faCircleExclamation, + faSpinnerThird, +} from "@fortawesome/pro-solid-svg-icons"; + +interface Props { + job: { + job_token: string; + request: { + maybe_model_title: string; + }; + status: { + status: string; + progress_percentage: number; + }; + }; +} + +function getPercent(status: string, percentage: number) { + if (["COMPLETE_FAILURE", "ATTEMPT_FAILED", "DEAD"].includes(status)) { + return 0; + } else { + return percentage; + } +} + +export function InProgressCard({ job }: Props) { + const completePercent = getPercent( + job.status.status, + job.status.progress_percentage, + ); + + const deleteJob = useCallback(async (jobToDelete: Props["job"]) => { + // Simulate API call + console.log("Deleting job:", jobToDelete.job_token); + }, []); + + return ( +
+
+
+ {(job.status.status === "STARTED" || + job.status.status === "PENDING") && ( + + )} + {(job.status.status === "COMPLETE_FAILURE" || + job.status.status === "ATTEMPT_FAILED") && ( + + )} +
+
+
+ {job.request.maybe_model_title || "Untitled"} +
+ +
+ {job.status.status.split("_").join(" ")}... {completePercent}% +
+
+
+ + {job.status.status !== "STARTED" && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/index.ts b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/index.ts new file mode 100644 index 0000000000..1077f3502f --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Activity/index.ts @@ -0,0 +1 @@ +export { Activity } from "./Activity"; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/BaseDialog.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/BaseDialog.tsx new file mode 100644 index 0000000000..21b4a0a3d3 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/BaseDialog.tsx @@ -0,0 +1,65 @@ +import { Fragment } from "react"; +import { + Dialog, + DialogPanel, + Transition, + TransitionChild, +} from "@headlessui/react"; +import { twMerge } from "tailwind-merge"; +import { dialogPanelStyles, paperWrapperStyles } from "../../components/styles"; + +interface BaseDialogProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; + className?: string; +} + +export const BaseDialog = ({ + isOpen, + onClose, + children, + className = "max-w-2xl", +}: BaseDialogProps) => { + return ( + + + + + + ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Button.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Button.tsx new file mode 100644 index 0000000000..fe83115ce5 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Button.tsx @@ -0,0 +1,112 @@ +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons"; +import { twMerge } from "tailwind-merge"; +import { ButtonHTMLAttributes } from "react"; + +export interface ButtonProps extends ButtonHTMLAttributes { + icon?: IconDefinition; + iconClassName?: string; + iconFlip?: boolean; + htmlFor?: string; + variant?: "primary" | "secondary" | "tertiary" | "action"; + loading?: boolean; +} + +export const Button = ({ + icon, + iconClassName, + children, + className: propsClassName, + htmlFor, + variant: propsVariant = "primary", + disabled, + iconFlip = false, + loading, + ...rest +}: ButtonProps) => { + function getVariantClassNames(variant: string) { + switch (variant) { + case "tertiary": { + return "bg-tertiary hover:bg-tertiary-400 text-white"; + } + case "secondary": { + return "bg-ui-controls hover:bg-ui-controls/80 text-white"; + } + case "action": { + return "bg-action hover:bg-action-500 text-white"; + } + case "primary": + default: { + return "bg-primary hover:bg-primary-600 text-white"; + } + } + } + + const disabledClass = twMerge( + disabled || loading ? "opacity-50 pointer-events-none" : "", + ); + + const className = twMerge( + "text-[15px] font-semibold rounded-lg px-3 py-2 shadow-sm", + "flex gap-2 items-center justify-center", + "transition-all duration-150", + getVariantClassNames(propsVariant), + propsClassName, + disabledClass, + ); + + // const ButtonType = htmlFor ? "label" : "button"; + if (htmlFor) { + return ( + + ); + } + return ( + + ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/ComboBox.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/ComboBox.tsx new file mode 100644 index 0000000000..9c5b8c3889 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/ComboBox.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { faChevronDown } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + Combobox as HeadlessComboBox, + ComboboxButton, + ComboboxInput, + ComboboxOption, + ComboboxOptions, +} from "@headlessui/react"; + +interface ComboBoxInterface { + options: string[]; + value: string; + onChange: (newVal: string) => void; +} +export const Combobox = ({ options, value, onChange }: ComboBoxInterface) => { + const [query, setQuery] = useState(""); + const filteredOptions = + query === "" + ? options + : options.filter((option) => { + return option.toLowerCase().includes(option.toLowerCase()); + }); + + return ( + setQuery("")} + > +
+ font} + onChange={(event) => setQuery(event.target.value)} + /> + + + +
+ + {filteredOptions.map((option, idx) => ( + + {option} + + ))} + +
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Gravatar.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Gravatar.tsx new file mode 100644 index 0000000000..c1ef66738b --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Gravatar.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import { twMerge } from "tailwind-merge"; +import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +interface Props { + size: number; + email_hash?: string; + username?: string; + avatarIndex?: number; + backgroundIndex?: number; + className?: string; +} + +function Gravatar(props: Props) { + //url to the local fallback image + const localDefaultUrl = + props.avatarIndex === undefined + ? "/resources/avatars/0.webp" + : `/resources/avatars/${props.avatarIndex}.webp`; + + // submitted fallback need to be a url served from our server for gravatar to grab it + const defaultImageUrl = + props.avatarIndex === undefined + ? "https://storyteller.ai/images/avatars/2000x2000/0.webp" + : `https://storyteller.ai/images/avatars/2000x2000/${props.avatarIndex}.webp`; + + // NB: Gravatar suggests URI encoding these: + // https://en.gravatar.com/site/implement/images/ + const encondedDefaultImage = encodeURIComponent(defaultImageUrl); + + const gravatarUrl = `https://www.gravatar.com/avatar/${props.email_hash}?s=${props.size}&d=${encondedDefaultImage}`; + + const [{ imgUrl, showLoader }, setState] = useState<{ + imgUrl: string; + showLoader: boolean; + }>({ + imgUrl: gravatarUrl, + showLoader: true, + }); + + const altText = props.username ? `${props.username}'s gravatar` : "gravatar"; + + function getBackgroundColor(backgroundIndex?: number): string { + switch (backgroundIndex) { + case 0: + return "#E66462"; + case 1: + return "#FD881B"; + case 2: + return "#E7C13C"; + case 3: + return "#4BA905"; + case 4: + return "#25B8A0"; + case 5: + return "#0078D1"; + case 6: + return "#7F52C1"; + case 7: + return "#FF66AC"; + case 8: + return "#259FEC"; + default: + return `#1a1a27`; + } + } + + return ( +
+ {showLoader && ( +
+ +
+ )} + {altText} { + setState((curr) => ({ + imgUrl: curr.imgUrl, + showLoader: false, + })); + }} + onError={() => { + setState((curr) => ({ + imgUrl: + curr.imgUrl !== localDefaultUrl ? localDefaultUrl : curr.imgUrl, + showLoader: false, + })); + }} + /> +
+ ); +} + +export { Gravatar }; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/HexEyedropPicker.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/HexEyedropPicker.tsx new file mode 100644 index 0000000000..9c0af09a8e --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/HexEyedropPicker.tsx @@ -0,0 +1,85 @@ +import { Button } from "./Button"; +import { faEyedropper } from "@fortawesome/pro-solid-svg-icons"; +import { twMerge } from "tailwind-merge"; + +type ColorPickerHTMLAttributes = Omit< + React.HTMLAttributes, + "color" | "onChange" | "onChangeCapture" +>; +interface ColorPickerBaseProps + extends ColorPickerHTMLAttributes { + color: T; + onChange: (newColor: T) => void; +} + +export const HexEyedropPicker = ({ + color, + onDropperChange, + dropperClassName, + pickerClassName, + onPickerChange, + Picker, +}: { + color: string; + onDropperChange?: (newColor: string) => void; + dropperClassName?: string; + pickerClassName?: string; + onPickerChange: (newColor: string) => void; + Picker: (props: Partial>) => JSX.Element; +}) => { + const handleDropperClick = () => { + if (!("EyeDropper" in window)) { + // NOTE: This shouldn't happen cuz we're using Chromium + console.error("EyeDropper not found in window"); + return; + } + + const startEyeDrop = async (): Promise => { + // @ts-ignore-next-line + const dropper = new window.EyeDropper(); + + try { + const result = await dropper.open(); + console.log(result); + return result.sRGBHex; + } catch (err) { + // User cancelled the picker + return null; + } + }; + + startEyeDrop().then((newColor) => { + if (newColor) { + onDropperChange?.(newColor); + } + }); + }; + + // const [isDragging, setIsDragging] = useState(false); + // const handleOnMouseDown = () => { + // setIsDragging(true); + // }; + // const handleOnMouseUp = () => { + // setIsDragging(false); + // }; + + return ( +
+
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Input.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Input.tsx new file mode 100644 index 0000000000..88efddb17f --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Input.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { twMerge } from "tailwind-merge"; +import { IconDefinition } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { kebabCase } from "~/utilities"; + +interface InputProps extends React.InputHTMLAttributes { + inputClassName?: string; + iconClassName?: string; + label?: string; + icon?: IconDefinition; + isError?: boolean; + errorMessage?: string; +} + +export const Input = React.forwardRef( + ( + { + label, + icon, + inputClassName, + iconClassName, + className, + id, + isError, + onBlur, + onFocus, + errorMessage, + ...rest + }: InputProps, + ref: React.ForwardedRef, + ) => { + return ( +
+ {label && ( + + )} + +
+ {icon && ( + + )} + ) => { + if (onFocus) { + onFocus(e); + } + }} + onBlur={(e: React.FocusEvent) => { + if (onBlur) { + onBlur(e); + } + }} + {...rest} + /> + {errorMessage && ( +
{errorMessage}
+ )} +
+
+ ); + }, +); + +Input.displayName = "Input"; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/InputNumber.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/InputNumber.tsx new file mode 100644 index 0000000000..7451e437a5 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/InputNumber.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useState } from "react"; +import { twMerge } from "tailwind-merge"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faChevronDown, faChevronUp } from "@fortawesome/pro-solid-svg-icons"; + +import { Input } from "./Input"; + +interface NumberInputProps { + value: number; + onChange: (value: number) => void; +} + +export const InputNumber = ({ value, onChange }: NumberInputProps) => { + const [inputValue, setInputValue] = useState(value.toString()); + + useEffect(() => { + setInputValue(Math.round(value * 100) / 100 + ""); + }, [value]); + + const handleChange = (event: React.ChangeEvent) => { + const newValue = event.target.value; + if ( + newValue === "" || + (!isNaN(Number(newValue)) && Number(newValue) <= 100) + ) { + setInputValue(newValue); + const numericValue = parseFloat(newValue); + if (newValue !== "") { + onChange(Math.round(numericValue * 100) / 100); + } + } + }; + + const handleBlur = () => { + if (inputValue === "") { + setInputValue(Math.round(value * 100) / 100 + ""); + } + }; + + const handleIncrement = () => { + const newValue = Math.min(Number(inputValue) + 1, 100); + setInputValue(Math.round(newValue * 100) / 100 + ""); + onChange(Math.round(newValue * 100) / 100); + }; + + const handleDecrement = () => { + const newValue = Math.max(Number(inputValue) - 1, 0); + setInputValue(Math.round(newValue * 100) / 100 + ""); + onChange(Math.round(newValue * 100) / 100); + }; + + const incrementButtonStyle = + "flex h-4 w-6 items-center justify-center text-xs text-white bg-ui-border hover:bg-secondary-500 outline-none focus:outline-none transition-all active:outline-none"; + + return ( +
+ + +
+ + +
+
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LabeledSelect.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LabeledSelect.tsx new file mode 100644 index 0000000000..2a89660b23 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LabeledSelect.tsx @@ -0,0 +1,68 @@ +import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; +import { Button } from "~/components/ui/Button"; +import { useState, useRef } from "react"; + +export interface LabeledSelectProps { + label: string; + value: string; + onChange: (value: string) => void; + options?: string[]; +} + +// TODO: finish up this hover drop down component (WIP) - BFlat + +export const LabeledSelect = ({ + label, + value, + onChange, + options = ["1", "2", "3", "4"], +}: LabeledSelectProps) => { + const [isOpen, setIsOpen] = useState(false); + const timeoutRef = useRef(); + + const handleMouseEnter = () => { + clearTimeout(timeoutRef.current); + setIsOpen(true); + }; + + const handleMouseLeave = () => { + timeoutRef.current = window.setTimeout(() => { + setIsOpen(false); + }, 100); + }; + + return ( + +
+ + {label} — {value} + + {isOpen && ( + +
+ {options.map((option) => ( + + ))} +
+
+ )} +
+
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LoadingBar.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LoadingBar.tsx new file mode 100644 index 0000000000..147fb5dd1a --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LoadingBar.tsx @@ -0,0 +1,65 @@ +import { MouseEventHandler } from "react"; +import { twMerge } from "tailwind-merge"; +import { faArrowRightRotate } from "@fortawesome/pro-solid-svg-icons"; +import { Button } from "./Button"; + +export enum LoadingBarStatus { + IDLE = "idle", + LOADING = "loading", + SUCCESS = "success", + ERROR = "error", +} +export interface LoadingBarProps { + colReverse?: boolean; + progress: number; + status: LoadingBarStatus; + message?: string; + onRetry?: MouseEventHandler; +} + +export const LoadingBar = ({ + colReverse, + progress = 0, + status, + onRetry, + message, +}: LoadingBarProps) => { + return ( +
+
+
+
+ +
+ {message && } + + {status === LoadingBarStatus.ERROR && ( + + )} +
+
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LoadingSpinner.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000000..96cb51911c --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/LoadingSpinner.tsx @@ -0,0 +1,33 @@ +import { Transition } from "@headlessui/react"; +import { twMerge } from "tailwind-merge"; +import { Spinner } from "./Spinner"; + +export const LoadingSpinner = ({ + isShowing, + message, +}: { + isShowing: boolean; + message?: string; +}) => { + return ( + +
+ + {message && } +
+
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/PanelBottom.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/PanelBottom.tsx new file mode 100644 index 0000000000..078a56e971 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/PanelBottom.tsx @@ -0,0 +1,69 @@ +import { HTMLAttributes, useState } from "react"; +import { faChevronDown, faChevronUp } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; +import { Transition } from "@headlessui/react"; + +export const PanelBottom = ({ + className, + children, + ...props +}: HTMLAttributes) => { + const [isOpen, setIsOpen] = useState(true); + + const gridClasses = twMerge( + "row-span-3 row-start-10", + "col-span-12 col-start-1", + ); + const buttonClasses = twMerge( + "w-12 h-6 bg-ui-panel", + "border-ui-border border-l border-t border-r rounded-t-md", + "px-4 flex items-center justify-center", + ); + + return ( + <> + {!isOpen && ( + + )} + +
+ {children} + + +
+
+ + ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/PanelRight.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/PanelRight.tsx new file mode 100644 index 0000000000..1d4fc39df2 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/PanelRight.tsx @@ -0,0 +1,66 @@ +import { HTMLAttributes, useState } from "react"; +import { + faChevronLeft, + faChevronRight, +} from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; +import { Transition } from "@headlessui/react"; + +export const PanelRight = ({ + className, + children, + ...props +}: HTMLAttributes) => { + const [isOpen, setIsOpen] = useState(true); + const gridClasses = twMerge( + "col-span-3 col-start-10", + "row-span-12 row-start-1", + ); + const buttonClasses = + "w-6 bg-ui-panel border-ui-border border-l border-t border-b rounded-l-md py-4"; + return ( + <> + {!isOpen && ( + + )} + +
+ {children} + + +
+
+ + ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Slider.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Slider.tsx new file mode 100644 index 0000000000..09fbd0ce70 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Slider.tsx @@ -0,0 +1,184 @@ +import { Transition } from "@headlessui/react"; +import React, { useState, useRef, useEffect, ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; +import { Button } from "./Button"; +import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons"; + +interface SliderProps { + min: number; + max: number; + value: number; + onChange: (value: number) => void; + step: number; + className?: string; + showTooltip?: boolean; + tooltipContent?: ReactNode; + suffix?: string; + innerLabel?: string; + showDecrement?: boolean; + showIncrement?: boolean; +} + +export const Slider = ({ + min, + max, + value, + onChange, + step, + className, + showTooltip, + tooltipContent, + suffix, + innerLabel, + showDecrement, + showIncrement, +}: SliderProps) => { + const [localValue, setLocalValue] = useState(value); + const sliderRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [showHandleTooltip, setShowHandleTooltip] = useState(false); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + const setClampedValue = (value: number) => { + const roundedValue = Math.round(value / step) * step; + const clampedValue = Math.min(Math.max(roundedValue, min), max); + setLocalValue(clampedValue); + onChange(clampedValue); + }; + + const updatePosition = (x: number) => { + if (!sliderRef.current) return; + const rect = sliderRef.current.getBoundingClientRect(); + const fraction = (x - rect.left) / rect.width; + const newValue = fraction * (max - min) + min; + setClampedValue(newValue); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + setShowHandleTooltip(true); + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + updatePosition(e.clientX); + }; + + const handleMouseMove = (e: MouseEvent) => { + e.preventDefault(); + updatePosition(e.clientX); + }; + + const handleMouseUp = (e: MouseEvent) => { + e.preventDefault(); + setIsDragging(false); + setShowHandleTooltip(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + const handleMouseEnter = () => { + if (!isDragging) { + setShowHandleTooltip(true); + } + }; + + const handleMouseLeave = () => { + if (!isDragging) { + setShowHandleTooltip(false); + } + }; + + const handleDecrement = () => { + setClampedValue(localValue - step); + }; + + const handleIncrement = () => { + setClampedValue(localValue + step); + }; + + const percentage = ((localValue - min) / (max - min)) * 100; + const displayValue = + localValue.toFixed(step.toString().split(".")[1]?.length || 0) + + (suffix || ""); + + return ( +
+ {showDecrement && ( +
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Spinner.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Spinner.tsx new file mode 100644 index 0000000000..1677b73aa0 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Spinner.tsx @@ -0,0 +1,14 @@ +import { twMerge } from "tailwind-merge"; +import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +export const Spinner = ({ className }: { className?: string }) => { + return ( +
+ +
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Switches.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Switches.tsx new file mode 100644 index 0000000000..f8ace5e00f --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/Switches.tsx @@ -0,0 +1,49 @@ +import { Field, Label, Switch as HeadlessUiSwitch } from "@headlessui/react"; +import { twMerge } from "tailwind-merge"; + +export const Switch = ({ + checked, + disabled, + label, + onChange, +}: { + checked: boolean; + disabled?: boolean | "semi"; + label?: string; + onChange: (newState: boolean) => void; +}) => { + const labelDisabledStyles = disabled ? "opacity-50" : ""; + const switchContainerBaseStyle = + "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-0 focus:ring-indigo-500 focus:ring-offset-0"; + const switchContainerStateStyle = checked + ? "bg-primary hover:bg-primary-400" + : "bg-gray-500 hover:bg-gray-400"; + const switchButtonBaseStyle = + "inline-block h-4 w-4 transform rounded-full bg-white transition-transform"; + const switchButtonStateStyle = checked ? "translate-x-6" : "translate-x-1"; + + return ( + + {label && ( + + )} + + + + + ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TabSelector.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TabSelector.tsx new file mode 100644 index 0000000000..368a9df203 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TabSelector.tsx @@ -0,0 +1,107 @@ +import React, { useRef, useEffect, useState } from "react"; +import { Tab, TabGroup, TabList } from "@headlessui/react"; +import { twMerge } from "tailwind-merge"; +import { Tooltip } from "./Tooltip"; + +export interface TabItem { + id: string; + label: string; +} + +interface TabSelectorProps { + tabs: TabItem[]; + activeTab: string; + onTabChange: (tabId: string) => void; + className?: string; + disabled?: boolean; + disabledMessage?: string; +} + +export const TabSelector: React.FC = ({ + tabs, + activeTab, + onTabChange, + className, + disabled, + disabledMessage, +}) => { + // Find the index of the active tab + const selectedIndex = tabs.findIndex((tab) => tab.id === activeTab); + const tabsRef = useRef<(HTMLElement | null)[]>([]); + const [indicatorStyle, setIndicatorStyle] = useState({ + left: 0, + width: 0, + }); + + // Handle tab change + const handleTabChange = (index: number) => { + onTabChange(tabs[index].id); + }; + + // Update the indicator position when the selected tab changes + useEffect(() => { + if (selectedIndex >= 0 && tabsRef.current[selectedIndex]) { + const currentTab = tabsRef.current[selectedIndex]; + if (currentTab) { + setIndicatorStyle({ + left: currentTab.offsetLeft, + width: currentTab.offsetWidth, + }); + } + } + }, [selectedIndex, tabs]); + + const TabGroupElement = ( + + + {/* Animated indicator */} +
+ + {tabs.map((tab, index) => ( + (tabsRef.current[index] = el)} + disabled={disabled} + className={({ selected }) => + twMerge( + "relative z-20 mx-0.5 min-w-max rounded-md border-2 border-transparent px-4 py-0.5 text-center font-semibold transition-all duration-200 ease-in-out", + selected ? "text-white" : "text-gray-300 hover:text-white", + disabled ? "cursor-not-allowed opacity-60" : "", + ) + } + > + {tab.label} + + ))} + + + ); + + return ( + <> + {disabled && ( + + {TabGroupElement} + + )} + {!disabled && TabGroupElement} + + ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TextArea.tsx b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TextArea.tsx new file mode 100644 index 0000000000..25b6180409 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/Page2d/components/ui/TextArea.tsx @@ -0,0 +1,99 @@ +import { TextareaHTMLAttributes, forwardRef, useEffect, useRef } from "react"; +import { twMerge } from "tailwind-merge"; +import { kebabCase } from "~/utilities"; + +export type ResizeType = + | "none" + | "both" + | "horizontal" + | "vertical" + | undefined; + +const resizeStyles = { + none: "resize-none", + both: "resize", + horizontal: "resize-x", + vertical: "resize-y", + undefined: "", +}; + +export interface TextareaInterface + extends TextareaHTMLAttributes { + label?: string; + resize?: ResizeType; + forceBlurOnOutsideClick?: boolean; +} + +export const Textarea = forwardRef( + ( + { + className, + label, + resize = "vertical", + id, + forceBlurOnOutsideClick, + ...rest + }: TextareaInterface, + ref, + ) => { + const resizeStyle = resizeStyles[resize]; + const textareaRef = useRef(null); + + // Function to blur the textarea when clicking outside of it + useEffect(() => { + if (!forceBlurOnOutsideClick) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + textareaRef.current && + !textareaRef.current.contains(event.target as Node) + ) { + textareaRef.current.blur(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [forceBlurOnOutsideClick]); + + return ( +
+ {label && } + +